Skip to content

Commit 0229d4f

Browse files
committed
feat: support prime leaderboard user rank card
1 parent 4cd7e90 commit 0229d4f

45 files changed

Lines changed: 951 additions & 27 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { type SVGProps, useId } from 'react';
2+
3+
const SvgBarChart = (props: SVGProps<SVGSVGElement>) => {
4+
const maskId = useId();
5+
6+
return (
7+
<svg
8+
width="20"
9+
height="20"
10+
viewBox="0 0 20 20"
11+
fill="none"
12+
xmlns="http://www.w3.org/2000/svg"
13+
{...props}
14+
>
15+
<mask id={maskId} fill="white">
16+
<rect x="12" width="8" height="20" rx="1.49998" />
17+
</mask>
18+
<rect
19+
x="12"
20+
width="8"
21+
height="20"
22+
rx="1.49998"
23+
stroke="currentColor"
24+
strokeWidth="4"
25+
mask={`url(#${maskId})`}
26+
/>
27+
<path
28+
d="M7.5 8H12.5C12.7761 8 13 8.22387 13 8.5V19H7.5C7.22387 19 7 18.7761 7 18.5V8.5C7 8.22387 7.22387 8 7.5 8Z"
29+
stroke="currentColor"
30+
strokeWidth="2"
31+
/>
32+
<path
33+
d="M1.5 14H6.5C6.77613 14 7 14.2239 7 14.5V19H1.5C1.22387 19 1 18.7761 1 18.5V14.5C1 14.2239 1.22387 14 1.5 14Z"
34+
stroke="currentColor"
35+
strokeWidth="2"
36+
/>
37+
</svg>
38+
);
39+
};
40+
41+
export default SvgBarChart;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { SVGProps } from 'react';
2+
3+
const SvgGraduationCap = (props: SVGProps<SVGSVGElement>) => (
4+
<svg
5+
width="20"
6+
height="20"
7+
viewBox="0 0 18.7507 18.7507"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
{...props}
11+
>
12+
<g transform="translate(0 1.25)">
13+
<path
14+
d="M9.37573 12.5C9.26693 12.5 9.16002 12.4715 9.06557 12.4175L3.59448 9.29059C3.54694 9.26314 3.493 9.2487 3.43811 9.24872C3.38322 9.24874 3.3293 9.26322 3.28178 9.29071C3.23426 9.31819 3.19481 9.3577 3.16742 9.40527C3.14002 9.45284 3.12564 9.50679 3.12573 9.56169V12.5C3.12563 12.6115 3.1554 12.7211 3.21193 12.8172C3.26846 12.9134 3.3497 12.9927 3.44721 13.0468L9.07221 16.1718C9.16506 16.2234 9.26952 16.2505 9.37573 16.2505C9.48194 16.2505 9.5864 16.2234 9.67924 16.1718L15.3042 13.0468C15.4018 12.9927 15.483 12.9134 15.5395 12.8172C15.5961 12.7211 15.6258 12.6115 15.6257 12.5V9.56169C15.6258 9.50679 15.6114 9.45284 15.584 9.40527C15.5566 9.3577 15.5172 9.31819 15.4697 9.29071C15.4222 9.26322 15.3682 9.24874 15.3133 9.24872C15.2584 9.2487 15.2045 9.26314 15.157 9.29059L9.68588 12.4175C9.59143 12.4715 9.48452 12.5 9.37573 12.5Z"
15+
fill="currentColor"
16+
/>
17+
<path
18+
d="M18.7476 5.56637C18.7476 5.56637 18.7476 5.56325 18.7476 5.56208C18.7375 5.46301 18.7039 5.36779 18.6495 5.28435C18.5951 5.20091 18.5216 5.13167 18.4351 5.08239L9.6851 0.0823878C9.59064 0.0283988 9.48373 0 9.37494 0C9.26615 0 9.15924 0.0283988 9.06478 0.0823878L0.314784 5.08239C0.219154 5.13706 0.139673 5.21604 0.0843939 5.31133C0.0291144 5.40661 0 5.51481 0 5.62497C0 5.73512 0.0291144 5.84332 0.0843939 5.93861C0.139673 6.03389 0.219154 6.11287 0.314784 6.16754L9.06478 11.1675C9.15924 11.2215 9.26615 11.2499 9.37494 11.2499C9.48373 11.2499 9.59064 11.2215 9.6851 11.1675L17.3835 6.76872C17.3954 6.76185 17.4089 6.75824 17.4227 6.75825C17.4364 6.75826 17.4499 6.7619 17.4618 6.76879C17.4737 6.77569 17.4835 6.78559 17.4904 6.79751C17.4972 6.80943 17.5008 6.82295 17.5007 6.83669V12.4824C17.5007 12.8187 17.7593 13.1074 18.0956 13.1242C18.1801 13.1283 18.2646 13.1151 18.3439 13.0856C18.4232 13.0561 18.4956 13.0107 18.5569 12.9524C18.6181 12.894 18.6669 12.8238 18.7002 12.7461C18.7335 12.6683 18.7507 12.5846 18.7507 12.5V5.62497C18.7507 5.60539 18.7496 5.58584 18.7476 5.56637Z"
19+
fill="currentColor"
20+
/>
21+
</g>
22+
</svg>
23+
);
24+
25+
export default SvgGraduationCap;

apps/evm/src/components/Icon/icons/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,5 @@ export { default as resilientOracle } from './resilientOracle';
101101
export { default as sunset } from './sunset';
102102
export { default as fullScreen } from './fullScreen';
103103
export { default as switch } from './switch';
104+
export { default as barChart } from './barChart';
105+
export { default as graduationCap } from './graduationCap';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { screen } from '@testing-library/react';
2+
3+
import { renderComponent } from 'testUtils/render';
4+
5+
import { EligibilityStatus } from '..';
6+
7+
const baseProps = {
8+
hasStakedXvs: true,
9+
isCandidate: true,
10+
isPrime: true,
11+
hasSupplied: true,
12+
gapXvsTokens: 5_432,
13+
};
14+
15+
describe('pages/PrimeLeaderboard/RankCard/EligibilityStatus', () => {
16+
it('congratulates Prime users who supplied and are candidates', () => {
17+
renderComponent(<EligibilityStatus {...baseProps} />);
18+
19+
expect(
20+
screen.getByText(
21+
"Congrats! You're in the Top 500 during last cycle and qualified for Prime Rewards.",
22+
),
23+
).toBeInTheDocument();
24+
});
25+
26+
it('shows the eligible message for candidates without Prime rewards', () => {
27+
renderComponent(<EligibilityStatus {...baseProps} isPrime={false} />);
28+
29+
expect(
30+
screen.getByText('You are currently eligible for Prime during the next cycle.'),
31+
).toBeInTheDocument();
32+
});
33+
34+
it('shows the exact XVS to stake when the gap to the top 500 is small', () => {
35+
renderComponent(<EligibilityStatus {...baseProps} isCandidate={false} gapXvsTokens={5_432} />);
36+
37+
expect(screen.getByText('5,432 XVS')).toBeInTheDocument();
38+
});
39+
40+
it('shows a generic message when the gap to the top 500 is large', () => {
41+
renderComponent(
42+
<EligibilityStatus {...baseProps} isCandidate={false} gapXvsTokens={200_000} />,
43+
);
44+
45+
expect(
46+
screen.getByText('Stake more XVS to compete for Prime during the next cycle.'),
47+
).toBeInTheDocument();
48+
});
49+
50+
it('prompts to stake when no XVS is staked', () => {
51+
renderComponent(<EligibilityStatus {...baseProps} hasStakedXvs={false} isCandidate={false} />);
52+
53+
expect(screen.getByText('Stake XVS to compete for Prime.')).toBeInTheDocument();
54+
});
55+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { cn } from '@venusprotocol/ui';
2+
import BigNumber from 'bignumber.js';
3+
4+
import { useTranslation } from 'libs/translations';
5+
6+
// Maximum XVS gap to the top #500 for which the exact amount left to stake is shown
7+
const TOP_500_GAP_THRESHOLD_XVS = 100_000;
8+
9+
export interface EligibilityStatusProps {
10+
hasStakedXvs: boolean;
11+
isCandidate: boolean;
12+
isPrime: boolean;
13+
hasSupplied: boolean;
14+
gapXvsTokens: number;
15+
// Optional inline content appended to the end of the status message (e.g. a leaderboard link)
16+
linkSlot?: React.ReactNode;
17+
className?: string;
18+
}
19+
20+
export const EligibilityStatus: React.FC<EligibilityStatusProps> = ({
21+
hasStakedXvs,
22+
isCandidate,
23+
isPrime,
24+
hasSupplied,
25+
gapXvsTokens,
26+
linkSlot,
27+
className,
28+
}) => {
29+
const { t, Trans } = useTranslation();
30+
31+
const isEligible = hasStakedXvs && isCandidate;
32+
33+
if (isEligible && isPrime && hasSupplied) {
34+
return (
35+
<p className={cn('text-b1r text-white', className)}>
36+
{t('primeLeaderboard.rankCard.eligibleSupplied')}
37+
{linkSlot}
38+
</p>
39+
);
40+
}
41+
42+
if (isEligible) {
43+
return (
44+
<p className={cn('text-b1r text-green', className)}>
45+
{t('primeLeaderboard.rankCard.eligible')}
46+
{linkSlot}
47+
</p>
48+
);
49+
}
50+
51+
let stakeMessage: React.ReactNode = t('primeLeaderboard.rankCard.stakePrompt');
52+
53+
if (hasStakedXvs && gapXvsTokens <= TOP_500_GAP_THRESHOLD_XVS) {
54+
stakeMessage = (
55+
<Trans
56+
i18nKey="primeLeaderboard.rankCard.stakeToReachTop"
57+
values={{ amount: new BigNumber(gapXvsTokens).toFormat() }}
58+
components={{ Highlight: <span className="text-b1s text-white" /> }}
59+
/>
60+
);
61+
} else if (hasStakedXvs) {
62+
stakeMessage = t('primeLeaderboard.rankCard.stakeMore');
63+
}
64+
65+
return (
66+
<div className={className}>
67+
<p className="text-b1r text-yellow">{t('primeLeaderboard.rankCard.notEligible')}</p>
68+
<p className="text-b1r text-white">
69+
{stakeMessage}
70+
{linkSlot}
71+
</p>
72+
</div>
73+
);
74+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import BigNumber from 'bignumber.js';
2+
3+
import { routes } from 'constants/routing';
4+
import { Link } from 'containers/Link';
5+
import { useTranslation } from 'libs/translations';
6+
import { shortenValueWithSuffix } from 'utilities';
7+
8+
import { EligibilityStatus } from '../EligibilityStatus';
9+
import { useGetPrimeRank } from '../useGetPrimeRank';
10+
11+
export interface FooterProps {
12+
hideLeaderboardLink?: boolean;
13+
}
14+
15+
export const Footer: React.FC<FooterProps> = ({ hideLeaderboardLink }) => {
16+
const { t, Trans } = useTranslation();
17+
18+
const { hasStakedXvs, isCandidate, isPrime, hasSupplied, rank, primeScore, gapXvsTokens } =
19+
useGetPrimeRank();
20+
21+
const rankLabel = hasStakedXvs ? `#${rank}` : '#-';
22+
const primeScoreLabel = hasStakedXvs
23+
? shortenValueWithSuffix({ value: new BigNumber(primeScore) })
24+
: '-';
25+
26+
return (
27+
<div className="flex flex-col gap-1 rounded-lg border border-dark-blue-hover p-3">
28+
<div className="flex items-start justify-between">
29+
<div className="flex flex-col">
30+
<span className="text-b1r text-light-grey">
31+
{t('primeLeaderboard.rankCard.rankLabel')}
32+
</span>
33+
34+
<span className="text-p1s text-white">{rankLabel}</span>
35+
</div>
36+
37+
<div className="flex flex-col items-end">
38+
<span className="text-b1r text-light-grey">
39+
{t('primeLeaderboard.rankCard.primeScoreLabel')}
40+
</span>
41+
42+
<span className="text-p1s text-white">{primeScoreLabel}</span>
43+
</div>
44+
</div>
45+
46+
<EligibilityStatus
47+
hasStakedXvs={hasStakedXvs}
48+
isCandidate={isCandidate}
49+
isPrime={isPrime}
50+
hasSupplied={hasSupplied}
51+
gapXvsTokens={gapXvsTokens}
52+
linkSlot={
53+
!hideLeaderboardLink && (
54+
<span className="text-white">
55+
{' '}
56+
<Trans
57+
i18nKey="primeLeaderboard.rankFooter.learnMore"
58+
components={{
59+
leaderboardLink: (
60+
<Link className="text-blue underline" to={routes.primeLeaderboard.path} />
61+
),
62+
}}
63+
/>
64+
</span>
65+
)
66+
}
67+
/>
68+
</div>
69+
);
70+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface PrimeRankData {
2+
hasStakedXvs: boolean;
3+
isCandidate: boolean;
4+
isPrime: boolean;
5+
hasSupplied: boolean;
6+
rank: number;
7+
primeScore: number;
8+
gapXvsTokens: number;
9+
}
10+
11+
// TODO: replace this placeholder with the rank data returned by the API
12+
const placeholderRankData: PrimeRankData = {
13+
hasStakedXvs: true,
14+
isCandidate: true,
15+
isPrime: true,
16+
hasSupplied: true,
17+
rank: 2,
18+
primeScore: 542_500_000,
19+
gapXvsTokens: 5_432,
20+
};
21+
22+
export const useGetPrimeRank = (): PrimeRankData => placeholderRankData;

apps/evm/src/containers/VaultCard/Simplified/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {
1919

2020
import { InstitutionalVaultModal } from 'containers/VaultCard/InstitutionalVaultModal';
2121
import { PendleVaultModal } from 'containers/VaultCard/PendleVaultModal';
22+
import { VenusVaultModal } from 'containers/VenusVaultModal';
2223
import { useState } from 'react';
23-
import { VenusVaultModal } from '../VenusVaultModal';
2424
import { Cell } from './Cell';
2525

2626
interface VaultCardSimplifiedProps {

apps/evm/src/containers/VaultCard/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState } from 'react';
33

44
import { Card, LabeledInlineContent, LayeredValues, NoticeWarning } from 'components';
55
import { CopyAddressButton } from 'containers/CopyAddressButton';
6+
import { VenusVaultModal } from 'containers/VenusVaultModal';
67
import useConvertMantissaToReadableTokenString from 'hooks/useConvertMantissaToReadableTokenString';
78
import { useTranslation } from 'libs/translations';
89
import { useAccountAddress } from 'libs/wallet';
@@ -23,7 +24,6 @@ import { PrimeEligibilityInlineContent } from './PrimeEligibilityInlineContent';
2324
import { Progress } from './Progress';
2425
import { StatusLabel } from './StatusLabel';
2526
import { VaultName } from './VaultName';
26-
import { VenusVaultModal } from './VenusVaultModal';
2727

2828
export interface VaultProps {
2929
vault: Vault;

apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/__tests__/index.spec.tsx renamed to apps/evm/src/containers/VenusVaultModal/Footer/__tests__/index.spec.tsx

File renamed without changes.

0 commit comments

Comments
 (0)