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..282a78d990 --- /dev/null +++ b/apps/evm/src/components/Icon/icons/barChart.tsx @@ -0,0 +1,42 @@ +import { type SVGProps, useId } from 'react'; + +const SvgBarChart = (props: SVGProps) => { + const maskId = `bar-chart-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; + + 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..87bc3b988c --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/EligibilityStatus/__tests__/index.spec.tsx @@ -0,0 +1,43 @@ +import { screen } from '@testing-library/react'; + +import { renderComponent } from 'testUtils/render'; + +import { EligibilityStatus } from '..'; + +const baseProps = { + hasStakedXvs: true, + isCandidate: true, + gapXvsTokens: 5_432, +}; + +describe('pages/PrimeLeaderboard/RankCard/EligibilityStatus', () => { + it('shows the eligible message for candidates', () => { + 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..2d9757fbe0 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/EligibilityStatus/index.tsx @@ -0,0 +1,61 @@ +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; + 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, + gapXvsTokens, + linkSlot, + className, +}) => { + const { t, Trans } = useTranslation(); + + const isEligible = hasStakedXvs && isCandidate; + + 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..3eb0412be2 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/Footer/index.tsx @@ -0,0 +1,63 @@ +import { routes } from 'constants/routing'; +import { Link } from 'containers/Link'; +import { useTranslation } from 'libs/translations'; + +import { EligibilityStatus } from '../EligibilityStatus'; +import { getRankLabels } from '../getRankLabels'; +import { useGetPrimeRank } from '../useGetPrimeRank'; + +export interface FooterProps { + hideLeaderboardLink?: boolean; +} + +export const Footer: React.FC = ({ hideLeaderboardLink }) => { + const { t, Trans } = useTranslation(); + + const rankData = useGetPrimeRank(); + const { hasStakedXvs, isCandidate, gapXvsTokens } = rankData; + + const { rankLabel, primeScoreLabel } = getRankLabels(rankData); + + return ( +
+
+
+ + {t('primeLeaderboard.rankCard.rankLabel')} + + + {rankLabel} +
+ +
+ + {t('primeLeaderboard.rankCard.primeScoreLabel')} + + + {primeScoreLabel} +
+
+ + + {' '} + + ), + }} + /> + + ) + } + /> +
+ ); +}; diff --git a/apps/evm/src/containers/PrimeRank/getRankLabels/index.ts b/apps/evm/src/containers/PrimeRank/getRankLabels/index.ts new file mode 100644 index 0000000000..c08f6c5144 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/getRankLabels/index.ts @@ -0,0 +1,21 @@ +import BigNumber from 'bignumber.js'; + +import { shortenValueWithSuffix } from 'utilities'; + +import type { PrimeRankData } from '../useGetPrimeRank'; + +export interface RankLabels { + rankLabel: string; + primeScoreLabel: string; +} + +export const getRankLabels = ({ + hasStakedXvs, + rank, + primeScore, +}: Pick): RankLabels => ({ + rankLabel: hasStakedXvs ? `#${rank}` : '#-', + primeScoreLabel: hasStakedXvs + ? shortenValueWithSuffix({ value: new BigNumber(primeScore) }) + : '-', +}); 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..8acb94e2e1 --- /dev/null +++ b/apps/evm/src/containers/PrimeRank/useGetPrimeRank/index.ts @@ -0,0 +1,18 @@ +export interface PrimeRankData { + hasStakedXvs: boolean; + isCandidate: 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, + 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={