Compare commits

...

6 Commits

Author SHA1 Message Date
asiaznik
a4edc2d6da
fix: ordinals not needed, mock removed 2024-02-02 17:20:37 +01:00
asiaznik
55f45c773b
fix: add test id to create button 2024-02-02 13:33:37 +01:00
asiaznik
8eb87756b8
fix: th ordinal for 11, 12, 13 2024-02-02 12:07:15 +01:00
asiaznik
d4cf248205
fix: th ordinal for 11, 12, 13 2024-02-02 00:05:41 +01:00
asiaznik
ba2ef3c086
chore: better looking card 2024-02-01 23:58:38 +01:00
asiaznik
1bb49f7a5c
feat(trading): team card 2024-02-01 22:52:15 +01:00
10 changed files with 364 additions and 94 deletions

View File

@ -16,6 +16,8 @@ import { CompetitionsLeaderboard } from '../../components/competitions/competiti
import { useTeams } from '../../lib/hooks/use-teams'; import { useTeams } from '../../lib/hooks/use-teams';
import take from 'lodash/take'; import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title'; import { usePageTitle } from '../../lib/hooks/use-page-title';
import { TeamCard } from '../../components/competitions/team-card';
import { useMyTeam } from '../../lib/hooks/use-my-team';
export const CompetitionsHome = () => { export const CompetitionsHome = () => {
const t = useT(); const t = useT();
@ -33,6 +35,13 @@ export const CompetitionsHome = () => {
const { data: teamsData, loading: teamsLoading } = useTeams(); const { data: teamsData, loading: teamsLoading } = useTeams();
const {
team: myTeam,
stats: myTeamStats,
games: myTeamGames,
rank: myTeamRank,
} = useMyTeam();
return ( return (
<ErrorBoundary> <ErrorBoundary>
<CompetitionsHeader title={t('Competitions')}> <CompetitionsHeader title={t('Competitions')}>
@ -43,68 +52,83 @@ export const CompetitionsHome = () => {
</p> </p>
</CompetitionsHeader> </CompetitionsHeader>
{/** Get started */} {/** Team card */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2> {myTeam ? (
<>
<h2 className="text-2xl mb-6">{t('My team')}</h2>
<div className="mb-12">
<TeamCard
team={myTeam}
rank={myTeamRank}
stats={myTeamStats}
games={myTeamGames}
/>
</div>
</>
) : (
<>
{/** Get started */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2>
<CompetitionsActionsContainer> <CompetitionsActionsContainer>
<CompetitionsAction <CompetitionsAction
variant="A" variant="A"
title={t('Create a team')} title={t('Create a team')}
description={t( description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)} )}
actionElement={ actionElement={
<TradingButton <TradingButton
intent={Intent.Primary} intent={Intent.Primary}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM()); navigate(Links.COMPETITIONS_CREATE_TEAM());
}} }}
data-testid="create-public-team-button" data-testId="create-public-team-button"
> >
{t('Create a public team')} {t('Create a public team')}
</TradingButton> </TradingButton>
} }
/> />
<CompetitionsAction <CompetitionsAction
variant="B" variant="B"
title={t('Solo team / lone wolf')} title={t('Solo team / lone wolf')}
description={t( description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)} )}
actionElement={ actionElement={
<TradingButton <TradingButton
intent={Intent.Primary} intent={Intent.Primary}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO()); navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO());
}} }}
data-testid="create-private-team-button" >
> {t('Create a private team')}
{t('Create a private team')} </TradingButton>
</TradingButton> }
} />
/> <CompetitionsAction
<CompetitionsAction variant="C"
variant="C" title={t('Join a team')}
title={t('Join a team')} description={t(
description={t( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' )}
)} actionElement={
actionElement={ <TradingButton
<TradingButton intent={Intent.Primary}
intent={Intent.Primary} onClick={(e) => {
onClick={(e) => { e.preventDefault();
e.preventDefault(); navigate(Links.COMPETITIONS_TEAMS());
navigate(Links.COMPETITIONS_TEAMS()); }}
}} >
data-testid="choose-team-button" {t('Choose a team')}
> </TradingButton>
{t('Choose a team')} }
</TradingButton> />
} </CompetitionsActionsContainer>
/> </>
</CompetitionsActionsContainer> )}
{/** List of available games */} {/** List of available games */}
<h2 className="text-2xl mb-6">{t('Games')}</h2> <h2 className="text-2xl mb-6">{t('Games')}</h2>

View File

@ -1,16 +1,24 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import { type Team } from '../../lib/hooks/use-team'; import { type Team } from '../../lib/hooks/use-team';
import { type ComponentProps } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit'; import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links'; import { Links } from '../../lib/links';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
export const UpdateTeamButton = ({ team }: { team: Team }) => { export const UpdateTeamButton = ({
team,
size = 'medium',
}: {
team: Pick<Team, 'teamId' | 'referrer'>;
size?: ComponentProps<typeof TradingAnchorButton>['size'];
}) => {
const t = useT(); const t = useT();
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
if (pubKey && !isReadOnly && pubKey === team.referrer) { if (pubKey && !isReadOnly && pubKey === team.referrer) {
return ( return (
<TradingAnchorButton <TradingAnchorButton
size={size}
data-testid="update-team-button" data-testid="update-team-button"
href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)} href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)}
intent={Intent.Info} intent={Intent.Info}

View File

@ -5,7 +5,11 @@ export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT = export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent'; 'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
export const Box = (props: HTMLAttributes<HTMLDivElement>) => { export const Box = ({
children,
backgroundImage,
...props
}: HTMLAttributes<HTMLDivElement> & { backgroundImage?: string }) => {
return ( return (
<div <div
{...props} {...props}
@ -13,9 +17,22 @@ export const Box = (props: HTMLAttributes<HTMLDivElement>) => {
BORDER_COLOR, BORDER_COLOR,
GRADIENT, GRADIENT,
'border rounded-lg', 'border rounded-lg',
'p-6', 'relative p-6 overflow-hidden',
props.className props.className
)} )}
/> >
{Boolean(backgroundImage?.length) && (
<div
className={classNames(
'pointer-events-none',
'bg-no-repeat bg-center bg-[length:500px_500px]',
'absolute top-0 left-0 w-full h-full -z-10 opacity-30 blur-lg'
)}
style={{ backgroundImage: `url("${backgroundImage}")` }}
></div>
)}
{children}
</div>
); );
}; };

View File

@ -3,7 +3,7 @@ import classNames from 'classnames';
const NUM_AVATARS = 20; const NUM_AVATARS = 20;
const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png'; const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png';
const getFallbackAvatar = (teamId: string) => { export const getFallbackAvatar = (teamId: string) => {
const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1) const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1)
.toString() .toString()
.padStart(2, '0'); // between 01 - 20 .padStart(2, '0'); // between 01 - 20

View File

@ -0,0 +1,154 @@
import { type TeamGame, type TeamStats } from '../../lib/hooks/use-team';
import { type TeamsFieldsFragment } from '../../lib/hooks/__generated__/Teams';
import { TeamAvatar, getFallbackAvatar } from './team-avatar';
import { FavoriteGame, Stat } from './team-stats';
import { useT } from '../../lib/use-t';
import { formatNumberRounded } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { Box } from './box';
import { Intent, Tooltip, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links';
import orderBy from 'lodash/orderBy';
import { take } from 'lodash';
import { DispatchMetricLabels } from '@vegaprotocol/types';
import classNames from 'classnames';
import { UpdateTeamButton } from '../../client-pages/competitions/update-team-button';
export const TeamCard = ({
rank,
team,
stats,
games,
}: {
rank: number;
team: TeamsFieldsFragment;
stats?: TeamStats;
games?: TeamGame[];
}) => {
const t = useT();
const lastGames = take(
orderBy(
games?.map((g) => ({
rank: g.team.rank,
metric: g.team.rewardMetric,
epoch: g.epoch,
})),
(i) => i.epoch,
'desc'
),
5
);
return (
<div
className={classNames(
'gap-6 grid grid-cols-1 grid-rows-1',
'md:grid-cols-3'
)}
>
{/** Card */}
<Box
backgroundImage={team.avatarUrl || getFallbackAvatar(team.teamId)}
className="flex flex-col items-center gap-3 min-w-[80px] lg:min-w-[112px]"
>
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<h1 className="calt lg:text-2xl" data-testid="team-name">
{team.name}
</h1>
{games && <FavoriteGame games={games} noLabel />}
<TradingAnchorButton
size="extra-small"
intent={Intent.Primary}
href={Links.COMPETITIONS_TEAM(team.teamId)}
>
{t('Profile')}
</TradingAnchorButton>
<UpdateTeamButton team={team} size="extra-small" />
</Box>
{/** Tiles */}
<Box className="w-full md:col-span-2">
<div
className={classNames(
'grid gap-3 w-full mb-4',
'md:grid-cols-3 md:grid-rows-2',
'grid-cols-2 grid-rows-3'
)}
>
<Stat
className="flex flex-col-reverse"
value={rank}
label={t('Rank')}
valueTestId="team-rank"
/>
<Stat
className="flex flex-col-reverse"
value={team.totalMembers || 0}
label={t('Members')}
valueTestId="members-count-stat"
/>
<Stat
className="flex flex-col-reverse"
value={stats?.totalGamesPlayed || 0}
label={t('Total games')}
valueTestId="total-games-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumVolume
? formatNumberRounded(
new BigNumber(stats.totalQuantumVolume || 0),
'1e3'
)
: 0
}
label={t('Total volume')}
valueTestId="total-volume-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumRewards
? formatNumberRounded(
new BigNumber(stats.totalQuantumRewards || 0),
'1e3'
)
: 0
}
label={t('Rewards paid out')}
valueTestId="rewards-paid-stat"
/>
</div>
<dl className="w-full pt-4 border-t border-vega-clight-700 dark:border-vega-cdark-700">
<dt className="mb-1 text-sm text-muted">
{t('Last {{games}} games result', {
replace: { games: lastGames.length || '' },
})}
</dt>
<dd className="flex flex-row flex-wrap gap-2">
{lastGames.length === 0 && t('None available')}
{lastGames.map((game, i) => (
<Tooltip key={i} description={DispatchMetricLabels[game.metric]}>
<button className="cursor-help text-sm bg-vega-clight-700 dark:bg-vega-cdark-700 px-2 py-1 rounded-full">
<RankLabel rank={game.rank} />
</button>
</Tooltip>
))}
</dd>
</dl>
</Box>
</div>
);
};
/**
* Sets the english ordinal for given rank only if the current language is set
* to english.
*/
const RankLabel = ({ rank }: { rank: number }) => {
const t = useT();
return t('place', { count: rank, ordinal: true });
};

View File

@ -15,6 +15,7 @@ import {
} from '../../lib/hooks/use-team'; } from '../../lib/hooks/use-team';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types'; import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types';
import classNames from 'classnames';
export const TeamStats = ({ export const TeamStats = ({
stats, stats,
@ -102,7 +103,13 @@ const LatestResults = ({ games }: { games: TeamGame[] }) => {
); );
}; };
const FavoriteGame = ({ games }: { games: TeamGame[] }) => { export const FavoriteGame = ({
games,
noLabel = false,
}: {
games: TeamGame[];
noLabel?: boolean;
}) => {
const t = useT(); const t = useT();
const rewardMetrics = games.map( const rewardMetrics = games.map(
@ -128,7 +135,13 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
return ( return (
<dl className="flex flex-col gap-1"> <dl className="flex flex-col gap-1">
<dt className="text-muted text-sm">{t('Favorite game')}</dt> <dt
className={classNames('text-muted text-sm', {
hidden: noLabel,
})}
>
{t('Favorite game')}
</dt>
<dd> <dd>
<Pill className="inline-flex items-center gap-1 bg-transparent text-sm"> <Pill className="inline-flex items-center gap-1 bg-transparent text-sm">
<VegaIcon <VegaIcon
@ -142,7 +155,7 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
); );
}; };
const StatSection = ({ children }: { children: ReactNode }) => { export const StatSection = ({ children }: { children: ReactNode }) => {
return ( return (
<section className="flex flex-col lg:flex-row gap-4 lg:gap-8"> <section className="flex flex-col lg:flex-row gap-4 lg:gap-8">
{children} {children}
@ -150,11 +163,11 @@ const StatSection = ({ children }: { children: ReactNode }) => {
); );
}; };
const StatSectionSeparator = () => { export const StatSectionSeparator = () => {
return <div className="hidden md:block border-r border-default" />; return <div className="hidden md:block border-r border-default" />;
}; };
const StatList = ({ children }: { children: ReactNode }) => { export const StatList = ({ children }: { children: ReactNode }) => {
return ( return (
<dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap"> <dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap">
{children} {children}
@ -162,19 +175,21 @@ const StatList = ({ children }: { children: ReactNode }) => {
); );
}; };
const Stat = ({ export const Stat = ({
value, value,
label, label,
tooltip, tooltip,
valueTestId, valueTestId,
className,
}: { }: {
value: ReactNode; value: ReactNode;
label: ReactNode; label: ReactNode;
tooltip?: string; tooltip?: string;
valueTestId?: string; valueTestId?: string;
className?: classNames.Argument;
}) => { }) => {
return ( return (
<div> <div className={classNames(className)}>
<dd className="text-3xl lg:text-4xl" data-testid={valueTestId}> <dd className="text-3xl lg:text-4xl" data-testid={valueTestId}>
{value} {value}
</dd> </dd>

View File

@ -1,15 +1,20 @@
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
query Teams($teamId: ID, $partyId: ID) { query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) { teams(teamId: $teamId, partyId: $partyId) {
edges { edges {
node { node {
teamId ...TeamsFields
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
} }
} }
} }

View File

@ -3,33 +3,40 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client'; import * as Apollo from '@apollo/client';
const defaultOptions = {} as const; const defaultOptions = {} as const;
export type TeamsFieldsFragment = { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, totalMembers: number };
export type TeamsQueryVariables = Types.Exact<{ export type TeamsQueryVariables = Types.Exact<{
teamId?: Types.InputMaybe<Types.Scalars['ID']>; teamId?: Types.InputMaybe<Types.Scalars['ID']>;
partyId?: Types.InputMaybe<Types.Scalars['ID']>; partyId?: Types.InputMaybe<Types.Scalars['ID']>;
}>; }>;
export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean } }> } | null }; export 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, totalMembers: number } }> } | null };
export const TeamsFieldsFragmentDoc = gql`
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
`;
export const TeamsDocument = gql` export const TeamsDocument = gql`
query Teams($teamId: ID, $partyId: ID) { query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) { teams(teamId: $teamId, partyId: $partyId) {
edges { edges {
node { node {
teamId ...TeamsFields
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
} }
} }
} }
} }
`; ${TeamsFieldsFragmentDoc}`;
/** /**
* __useTeamsQuery__ * __useTeamsQuery__

View File

@ -0,0 +1,25 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import compact from 'lodash/compact';
import first from 'lodash/first';
import { useTeamsQuery } from './__generated__/Teams';
import { useTeam } from './use-team';
import { useTeams } from './use-teams';
export const useMyTeam = () => {
const { pubKey } = useVegaWallet();
const { data: teams } = useTeams();
const { data: maybeMyTeam } = useTeamsQuery({
variables: {
partyId: pubKey,
},
skip: !pubKey,
fetchPolicy: 'cache-and-network',
});
const team = first(compact(maybeMyTeam?.teams?.edges.map((n) => n.node)));
const rank = teams.findIndex((t) => t.teamId === team?.teamId) + 1;
const { games, stats } = useTeam(team?.teamId);
return { team, stats, games, rank };
};

View File

@ -417,6 +417,7 @@
"myVolume_other": "My volume (last {{count}} epochs)", "myVolume_other": "My volume (last {{count}} epochs)",
"numberEpochs": "{{count}} epochs", "numberEpochs": "{{count}} epochs",
"numberEpochs_one": "{{count}} epoch", "numberEpochs_one": "{{count}} epoch",
"Rewards earned": "Rewards earned",
"Rewards paid out": "Rewards paid out", "Rewards paid out": "Rewards paid out",
"{{reward}}x": "{{reward}}x", "{{reward}}x": "{{reward}}x",
"userActive": "{{active}} trader: {{count}} epochs so far", "userActive": "{{active}} trader: {{count}} epochs so far",
@ -431,5 +432,19 @@
"{{assetSymbol}} Reward pot": "{{assetSymbol}} Reward pot", "{{assetSymbol}} Reward pot": "{{assetSymbol}} Reward pot",
"{{checkedAssets}} Assets": "{{checkedAssets}} Assets", "{{checkedAssets}} Assets": "{{checkedAssets}} Assets",
"{{distance}} ago": "{{distance}} ago", "{{distance}} ago": "{{distance}} ago",
"{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision" "{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision",
"My team": "My team",
"Profile": "Profile",
"Last {{games}} games result_one": "Last game result",
"Last {{games}} games result_other": "Last {{games}} games result",
"Leaderboard": "Leaderboard",
"View all teams": "View all teams",
"Competitions": "Competitions",
"Be a team player! Participate in games and work together to rake in as much profit to win.": "Be a team player! Participate in games and work together to rake in as much profit to win.",
"Create a public team": "Create a public team",
"Create a private team": "Create a private team",
"Choose a team": "Choose a team",
"Join a team": "Join a team",
"Solo team / lone wolf": "Solo team / lone wolf",
"Choose a team to get involved": "Choose a team to get involved"
} }