From e10164d762b2a5899ba1a2f55e8f21df4cbf0b7c Mon Sep 17 00:00:00 2001 From: cuzz-venus Date: Mon, 15 Jun 2026 22:49:51 +0800 Subject: [PATCH 1/5] feat: support prime leaderboard user rank card --- .../src/components/Icon/icons/barChart.tsx | 41 +++++++ .../components/Icon/icons/graduationCap.tsx | 25 +++++ apps/evm/src/components/Icon/icons/index.ts | 2 + .../__tests__/index.spec.tsx | 55 +++++++++ .../PrimeRank/EligibilityStatus/index.tsx | 74 ++++++++++++ .../src/containers/PrimeRank/Footer/index.tsx | 70 ++++++++++++ .../PrimeRank/useGetPrimeRank/index.ts | 22 ++++ .../containers/VaultCard/Simplified/index.tsx | 2 +- apps/evm/src/containers/VaultCard/index.tsx | 2 +- .../Footer/__tests__/index.spec.tsx | 0 .../calculateDailyVaultEarnings/index.ts | 0 .../VenusVaultModal/Footer/index.tsx | 0 .../StakeForm/__tests__/index.spec.tsx | 0 .../VenusVaultModal/StakeForm/index.tsx | 27 ++++- .../__tests__/index.spec.tsx | 0 .../WithdrawFromVaiVaultForm/index.tsx | 2 +- .../__tests__/index.spec.tsx | 0 .../RequestWithdrawalForm/index.tsx | 4 +- .../__snapshots__/index.spec.tsx.snap | 0 .../__tests__/index.spec.tsx | 0 .../WithdrawalRequestList/index.tsx | 0 .../WithdrawFromVestingVaultForm/index.tsx | 0 .../VenusVaultModal/WithdrawTab/index.tsx | 0 .../{VaultCard => }/VenusVaultModal/index.tsx | 18 ++- .../libs/translations/translations/en.json | 31 +++++ .../libs/translations/translations/ja.json | 31 +++++ .../libs/translations/translations/th.json | 31 +++++ .../libs/translations/translations/tr.json | 31 +++++ .../libs/translations/translations/vi.json | 31 +++++ .../translations/translations/zh-Hans.json | 31 +++++ .../translations/translations/zh-Hant.json | 31 +++++ .../RankCard/ConnectPrompt/index.tsx | 28 +++++ .../RankCard/RankActions/index.tsx | 35 ++++++ .../RankCard/RankSummary/index.tsx | 26 +++++ .../RankCard/__tests__/index.spec.tsx | 35 ++++++ .../pages/PrimeLeaderboard/RankCard/index.tsx | 65 +++++++++-- .../RankSection/__tests__/index.spec.tsx | 21 ++++ .../PrimeLeaderboard/RankSection/index.tsx | 23 ++++ .../RulesModal/__tests__/index.spec.tsx | 15 +++ .../PrimeLeaderboard/RulesModal/constants.ts | 6 + .../PrimeLeaderboard/RulesModal/index.tsx | 106 ++++++++++++++++++ .../StakeXvsModal/__tests__/index.spec.tsx | 22 ++++ .../PrimeLeaderboard/StakeXvsModal/index.tsx | 25 +++++ .../PrimeLeaderboard/__tests__/index.spec.tsx | 6 +- apps/evm/src/pages/PrimeLeaderboard/index.tsx | 4 +- 45 files changed, 951 insertions(+), 27 deletions(-) create mode 100644 apps/evm/src/components/Icon/icons/barChart.tsx create mode 100644 apps/evm/src/components/Icon/icons/graduationCap.tsx create mode 100644 apps/evm/src/containers/PrimeRank/EligibilityStatus/__tests__/index.spec.tsx create mode 100644 apps/evm/src/containers/PrimeRank/EligibilityStatus/index.tsx create mode 100644 apps/evm/src/containers/PrimeRank/Footer/index.tsx create mode 100644 apps/evm/src/containers/PrimeRank/useGetPrimeRank/index.ts rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/Footer/__tests__/index.spec.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/Footer/calculateDailyVaultEarnings/index.ts (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/Footer/index.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/StakeForm/__tests__/index.spec.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/StakeForm/index.tsx (75%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVaiVaultForm/__tests__/index.spec.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVaiVaultForm/index.tsx (96%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/RequestWithdrawalForm/__tests__/index.spec.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/RequestWithdrawalForm/index.tsx (98%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/WithdrawalRequestList/__tests__/__snapshots__/index.spec.tsx.snap (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/WithdrawalRequestList/__tests__/index.spec.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/WithdrawalRequestList/index.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/WithdrawFromVestingVaultForm/index.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/WithdrawTab/index.tsx (100%) rename apps/evm/src/containers/{VaultCard => }/VenusVaultModal/index.tsx (71%) create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankCard/ConnectPrompt/index.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankCard/RankActions/index.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankCard/RankSummary/index.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankCard/__tests__/index.spec.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankSection/__tests__/index.spec.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RankSection/index.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RulesModal/__tests__/index.spec.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RulesModal/constants.ts create mode 100644 apps/evm/src/pages/PrimeLeaderboard/RulesModal/index.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/StakeXvsModal/__tests__/index.spec.tsx create mode 100644 apps/evm/src/pages/PrimeLeaderboard/StakeXvsModal/index.tsx diff --git a/apps/evm/src/components/Icon/icons/barChart.tsx b/apps/evm/src/components/Icon/icons/barChart.tsx new file mode 100644 index 0000000000..17884eea87 --- /dev/null +++ b/apps/evm/src/components/Icon/icons/barChart.tsx @@ -0,0 +1,41 @@ +import { type SVGProps, useId } from 'react'; + +const SvgBarChart = (props: SVGProps) => { + const maskId = useId(); + + return ( + + + + + + + + + ); +}; + +export default SvgBarChart; diff --git a/apps/evm/src/components/Icon/icons/graduationCap.tsx b/apps/evm/src/components/Icon/icons/graduationCap.tsx new file mode 100644 index 0000000000..d13ba7e57b --- /dev/null +++ b/apps/evm/src/components/Icon/icons/graduationCap.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react'; + +const SvgGraduationCap = (props: SVGProps) => ( + + + + + + +); + +export default SvgGraduationCap; diff --git a/apps/evm/src/components/Icon/icons/index.ts b/apps/evm/src/components/Icon/icons/index.ts index 7dec646edf..6029313a50 100644 --- a/apps/evm/src/components/Icon/icons/index.ts +++ b/apps/evm/src/components/Icon/icons/index.ts @@ -101,3 +101,5 @@ export { default as resilientOracle } from './resilientOracle'; export { default as sunset } from './sunset'; export { default as fullScreen } from './fullScreen'; export { default as switch } from './switch'; +export { default as barChart } from './barChart'; +export { default as graduationCap } from './graduationCap'; diff --git a/apps/evm/src/containers/PrimeRank/EligibilityStatus/__tests__/index.spec.tsx b/apps/evm/src/containers/PrimeRank/EligibilityStatus/__tests__/index.spec.tsx new file mode 100644 index 0000000000..3225e4c5ad --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/EligibilityStatus/__tests__/index.spec.tsx @@ -0,0 +1,55 @@ +import { screen } from '@testing-library/react'; + +import { renderComponent } from 'testUtils/render'; + +import { EligibilityStatus } from '..'; + +const baseProps = { + hasStakedXvs: true, + isCandidate: true, + isPrime: true, + hasSupplied: true, + gapXvsTokens: 5_432, +}; + +describe('pages/PrimeLeaderboard/RankCard/EligibilityStatus', () => { + it('congratulates Prime users who supplied and are candidates', () => { + renderComponent(); + + expect( + screen.getByText( + "Congrats! You're in the Top 500 during last cycle and qualified for Prime Rewards.", + ), + ).toBeInTheDocument(); + }); + + it('shows the eligible message for candidates without Prime rewards', () => { + renderComponent(); + + expect( + screen.getByText('You are currently eligible for Prime during the next cycle.'), + ).toBeInTheDocument(); + }); + + it('shows the exact XVS to stake when the gap to the top 500 is small', () => { + renderComponent(); + + expect(screen.getByText('5,432 XVS')).toBeInTheDocument(); + }); + + it('shows a generic message when the gap to the top 500 is large', () => { + renderComponent( + , + ); + + expect( + screen.getByText('Stake more XVS to compete for Prime during the next cycle.'), + ).toBeInTheDocument(); + }); + + it('prompts to stake when no XVS is staked', () => { + renderComponent(); + + expect(screen.getByText('Stake XVS to compete for Prime.')).toBeInTheDocument(); + }); +}); diff --git a/apps/evm/src/containers/PrimeRank/EligibilityStatus/index.tsx b/apps/evm/src/containers/PrimeRank/EligibilityStatus/index.tsx new file mode 100644 index 0000000000..7558c4c8d7 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/EligibilityStatus/index.tsx @@ -0,0 +1,74 @@ +import { cn } from '@venusprotocol/ui'; +import BigNumber from 'bignumber.js'; + +import { useTranslation } from 'libs/translations'; + +// Maximum XVS gap to the top #500 for which the exact amount left to stake is shown +const TOP_500_GAP_THRESHOLD_XVS = 100_000; + +export interface EligibilityStatusProps { + hasStakedXvs: boolean; + isCandidate: boolean; + isPrime: boolean; + hasSupplied: boolean; + gapXvsTokens: number; + // Optional inline content appended to the end of the status message (e.g. a leaderboard link) + linkSlot?: React.ReactNode; + className?: string; +} + +export const EligibilityStatus: React.FC = ({ + hasStakedXvs, + isCandidate, + isPrime, + hasSupplied, + gapXvsTokens, + linkSlot, + className, +}) => { + const { t, Trans } = useTranslation(); + + const isEligible = hasStakedXvs && isCandidate; + + if (isEligible && isPrime && hasSupplied) { + return ( +

+ {t('primeLeaderboard.rankCard.eligibleSupplied')} + {linkSlot} +

+ ); + } + + if (isEligible) { + return ( +

+ {t('primeLeaderboard.rankCard.eligible')} + {linkSlot} +

+ ); + } + + let stakeMessage: React.ReactNode = t('primeLeaderboard.rankCard.stakePrompt'); + + if (hasStakedXvs && gapXvsTokens <= TOP_500_GAP_THRESHOLD_XVS) { + stakeMessage = ( + }} + /> + ); + } else if (hasStakedXvs) { + stakeMessage = t('primeLeaderboard.rankCard.stakeMore'); + } + + return ( +
+

{t('primeLeaderboard.rankCard.notEligible')}

+

+ {stakeMessage} + {linkSlot} +

+
+ ); +}; diff --git a/apps/evm/src/containers/PrimeRank/Footer/index.tsx b/apps/evm/src/containers/PrimeRank/Footer/index.tsx new file mode 100644 index 0000000000..14922a2b0c --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/Footer/index.tsx @@ -0,0 +1,70 @@ +import BigNumber from 'bignumber.js'; + +import { routes } from 'constants/routing'; +import { Link } from 'containers/Link'; +import { useTranslation } from 'libs/translations'; +import { shortenValueWithSuffix } from 'utilities'; + +import { EligibilityStatus } from '../EligibilityStatus'; +import { useGetPrimeRank } from '../useGetPrimeRank'; + +export interface FooterProps { + hideLeaderboardLink?: boolean; +} + +export const Footer: React.FC = ({ hideLeaderboardLink }) => { + const { t, Trans } = useTranslation(); + + const { hasStakedXvs, isCandidate, isPrime, hasSupplied, rank, primeScore, gapXvsTokens } = + useGetPrimeRank(); + + const rankLabel = hasStakedXvs ? `#${rank}` : '#-'; + const primeScoreLabel = hasStakedXvs + ? shortenValueWithSuffix({ value: new BigNumber(primeScore) }) + : '-'; + + return ( +
+
+
+ + {t('primeLeaderboard.rankCard.rankLabel')} + + + {rankLabel} +
+ +
+ + {t('primeLeaderboard.rankCard.primeScoreLabel')} + + + {primeScoreLabel} +
+
+ + + {' '} + + ), + }} + /> + + ) + } + /> +
+ ); +}; diff --git a/apps/evm/src/containers/PrimeRank/useGetPrimeRank/index.ts b/apps/evm/src/containers/PrimeRank/useGetPrimeRank/index.ts new file mode 100644 index 0000000000..ad1b0159d6 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/useGetPrimeRank/index.ts @@ -0,0 +1,22 @@ +export interface PrimeRankData { + hasStakedXvs: boolean; + isCandidate: boolean; + isPrime: boolean; + hasSupplied: boolean; + rank: number; + primeScore: number; + gapXvsTokens: number; +} + +// TODO: replace this placeholder with the rank data returned by the API +const placeholderRankData: PrimeRankData = { + hasStakedXvs: true, + isCandidate: true, + isPrime: true, + hasSupplied: true, + rank: 2, + primeScore: 542_500_000, + gapXvsTokens: 5_432, +}; + +export const useGetPrimeRank = (): PrimeRankData => placeholderRankData; diff --git a/apps/evm/src/containers/VaultCard/Simplified/index.tsx b/apps/evm/src/containers/VaultCard/Simplified/index.tsx index 795b222e39..adc4a43807 100644 --- a/apps/evm/src/containers/VaultCard/Simplified/index.tsx +++ b/apps/evm/src/containers/VaultCard/Simplified/index.tsx @@ -19,8 +19,8 @@ import { import { InstitutionalVaultModal } from 'containers/VaultCard/InstitutionalVaultModal'; import { PendleVaultModal } from 'containers/VaultCard/PendleVaultModal'; +import { VenusVaultModal } from 'containers/VenusVaultModal'; import { useState } from 'react'; -import { VenusVaultModal } from '../VenusVaultModal'; import { Cell } from './Cell'; interface VaultCardSimplifiedProps { diff --git a/apps/evm/src/containers/VaultCard/index.tsx b/apps/evm/src/containers/VaultCard/index.tsx index 749cc4f725..51d10b19f8 100644 --- a/apps/evm/src/containers/VaultCard/index.tsx +++ b/apps/evm/src/containers/VaultCard/index.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Card, LabeledInlineContent, LayeredValues, NoticeWarning } from 'components'; import { CopyAddressButton } from 'containers/CopyAddressButton'; +import { VenusVaultModal } from 'containers/VenusVaultModal'; import useConvertMantissaToReadableTokenString from 'hooks/useConvertMantissaToReadableTokenString'; import { useTranslation } from 'libs/translations'; import { useAccountAddress } from 'libs/wallet'; @@ -23,7 +24,6 @@ import { PrimeEligibilityInlineContent } from './PrimeEligibilityInlineContent'; import { Progress } from './Progress'; import { StatusLabel } from './StatusLabel'; import { VaultName } from './VaultName'; -import { VenusVaultModal } from './VenusVaultModal'; export interface VaultProps { vault: Vault; diff --git a/apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/__tests__/index.spec.tsx b/apps/evm/src/containers/VenusVaultModal/Footer/__tests__/index.spec.tsx similarity index 100% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/__tests__/index.spec.tsx rename to apps/evm/src/containers/VenusVaultModal/Footer/__tests__/index.spec.tsx diff --git a/apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/calculateDailyVaultEarnings/index.ts b/apps/evm/src/containers/VenusVaultModal/Footer/calculateDailyVaultEarnings/index.ts similarity index 100% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/calculateDailyVaultEarnings/index.ts rename to apps/evm/src/containers/VenusVaultModal/Footer/calculateDailyVaultEarnings/index.ts diff --git a/apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/index.tsx b/apps/evm/src/containers/VenusVaultModal/Footer/index.tsx similarity index 100% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/Footer/index.tsx rename to apps/evm/src/containers/VenusVaultModal/Footer/index.tsx diff --git a/apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/__tests__/index.spec.tsx b/apps/evm/src/containers/VenusVaultModal/StakeForm/__tests__/index.spec.tsx similarity index 100% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/__tests__/index.spec.tsx rename to apps/evm/src/containers/VenusVaultModal/StakeForm/__tests__/index.spec.tsx diff --git a/apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/index.tsx b/apps/evm/src/containers/VenusVaultModal/StakeForm/index.tsx similarity index 75% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/index.tsx rename to apps/evm/src/containers/VenusVaultModal/StakeForm/index.tsx index 0bed53d3eb..179ffaedb2 100644 --- a/apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/index.tsx +++ b/apps/evm/src/containers/VenusVaultModal/StakeForm/index.tsx @@ -2,23 +2,34 @@ import BigNumber from 'bignumber.js'; import { useGetBalanceOf, useStakeInVault } from 'clients/api'; import { NULL_ADDRESS } from 'constants/address'; +import { Footer as PrimeRankFooter } from 'containers/PrimeRank/Footer'; +import { TransactionForm } from 'containers/VaultCard/TransactionForm'; import { useForm } from 'containers/VaultCard/useForm'; import { useGetContractAddress } from 'hooks/useGetContractAddress'; +import { useIsFeatureEnabled } from 'hooks/useIsFeatureEnabled'; import useTokenApproval from 'hooks/useTokenApproval'; import { useTranslation } from 'libs/translations'; import { useAccountAddress } from 'libs/wallet'; -import type { Vault } from 'types'; +import { type Vault, VaultCategory } from 'types'; import { convertMantissaToTokens, convertTokensToMantissa } from 'utilities'; -import { TransactionForm } from '../../TransactionForm'; import { Footer } from '../Footer'; export interface StakeFormProps { vault: Vault; onClose: () => void; + hidePrimeLeaderboardLink?: boolean; } -export const StakeForm: React.FC = ({ vault, onClose }) => { +export const StakeForm: React.FC = ({ + vault, + onClose, + hidePrimeLeaderboardLink, +}) => { const { t } = useTranslation(); + + const isPrimeLeaderboardEnabled = useIsFeatureEnabled({ name: 'primeLeaderboard' }); + const showPrimeRankFooter = + isPrimeLeaderboardEnabled && vault.category === VaultCategory.GOVERNANCE; const { accountAddress } = useAccountAddress(); const fromToken = vault.stakedToken; @@ -101,7 +112,15 @@ export const StakeForm: React.FC = ({ vault, onClose }) => { fromTokenFieldLabel={t('vaultCard.vaultModal.stakeForm.depositField.label')} submitButtonLabel={t('vaultCard.vaultModal.stakeForm.submitButton.label')} fromTokenPriceCents={vault.stakedTokenPriceCents.toNumber()} - footer={