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 d5567b95dc..bd47c7984f 100644 --- a/apps/evm/src/components/Icon/icons/index.ts +++ b/apps/evm/src/components/Icon/icons/index.ts @@ -100,3 +100,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/InstitutionalVaultModal/PositionTab/DepositForm/index.tsx b/apps/evm/src/containers/VaultCard/InstitutionalVaultModal/PositionTab/DepositForm/index.tsx index f3102df0d2..4316a0bbcb 100644 --- a/apps/evm/src/containers/VaultCard/InstitutionalVaultModal/PositionTab/DepositForm/index.tsx +++ b/apps/evm/src/containers/VaultCard/InstitutionalVaultModal/PositionTab/DepositForm/index.tsx @@ -4,8 +4,8 @@ import { useGetBalanceOf, useStakeIntoInstitutionalVault } from 'clients/api'; import { NoticeInfo } from 'components'; import { NULL_ADDRESS } from 'constants/address'; import { Link } from 'containers/Link'; -import { TransactionForm } from 'containers/VaultCard/TransactionForm'; -import { useForm } from 'containers/VaultCard/useForm'; +import { VaultForm } from 'containers/VaultForm'; +import { useVaultForm } from 'hooks/useVaultForm'; import { useTranslation } from 'libs/translations'; import { useAccountAddress } from 'libs/wallet'; import type { InstitutionalVault } from 'types'; @@ -52,7 +52,7 @@ export const DepositForm: React.FC = ({ vault, onClose }) => { token: vault.stakedToken, }); - const form = useForm({ + const form = useVaultForm({ fromToken: vault.stakedToken, limitFromTokens, minFromTokens, @@ -77,7 +77,7 @@ export const DepositForm: React.FC = ({ vault, onClose }) => { return (
- = ({ vault, onClose }) => { vault.asset.supplyCapTokens, ]); - const form = useForm({ + const form = useVaultForm({ limitFromTokens, walletSpendingLimitTokens: fromTokenWalletSpendingLimitTokens, fromToken, @@ -149,7 +149,7 @@ export const DepositForm: React.FC = ({ vault, onClose }) => { : PLACEHOLDER_KEY; return ( - = ({ vault, onClose }) => const limitFromTokens = userStakedTokens; - const form = useForm({ + const form = useVaultForm({ limitFromTokens, fromToken, }); @@ -150,7 +150,7 @@ export const WithdrawForm: React.FC = ({ vault, onClose }) => return (
- > = {}) => ({ @@ -45,7 +45,7 @@ const makeUseDelegateApprovalOutput = ( ...overrides, }) as ReturnType; -const baseProps: Omit = { +const baseProps: Omit = { fromToken: xvs, limitFromTokens: new BigNumber(100), fromTokenFieldLabel: 'Deposit', @@ -70,7 +70,7 @@ const renderTransactionForm = ({ walletSpendingLimitTokens = new BigNumber(40), ...options }: { - props?: Partial>; + props?: Partial>; accountAddress?: string; walletSpendingLimitTokens?: BigNumber; } = {}) => { @@ -89,7 +89,7 @@ const renderTransactionForm = ({ walletSpendingLimitTokens, }); - return ; + return ; }; return { @@ -100,7 +100,7 @@ const renderTransactionForm = ({ vi.mock('hooks/useDelegateApproval'); -describe('TransactionForm', () => { +describe('VaultForm', () => { beforeEach(() => { (useGetBalanceOf as Mock).mockReturnValue({ data: { @@ -419,7 +419,7 @@ describe('TransactionForm', () => { swapQuoteError: { type: 'pendleSwapQuote', code: 'PENDLE_NO_ROUTE_FOUND', - } as TransactionFormProps['swapQuoteError'], + } as VaultFormProps['swapQuoteError'], }, walletSpendingLimitTokens: new BigNumber(120), }); diff --git a/apps/evm/src/containers/VaultCard/TransactionForm/getPendleQuoteValidationMessage/index.ts b/apps/evm/src/containers/VaultForm/getPendleQuoteValidationMessage/index.ts similarity index 100% rename from apps/evm/src/containers/VaultCard/TransactionForm/getPendleQuoteValidationMessage/index.ts rename to apps/evm/src/containers/VaultForm/getPendleQuoteValidationMessage/index.ts diff --git a/apps/evm/src/containers/VaultCard/TransactionForm/index.tsx b/apps/evm/src/containers/VaultForm/index.tsx similarity index 98% rename from apps/evm/src/containers/VaultCard/TransactionForm/index.tsx rename to apps/evm/src/containers/VaultForm/index.tsx index ea3a0bb0f6..781fdcd692 100644 --- a/apps/evm/src/containers/VaultCard/TransactionForm/index.tsx +++ b/apps/evm/src/containers/VaultForm/index.tsx @@ -16,6 +16,7 @@ import { import { RhfTokenTextField } from 'containers/Form'; import { type Approval, TxFormSubmitButton } from 'containers/TxFormSubmitButton'; import useTokenApproval from 'hooks/useTokenApproval'; +import type { FormValues } from 'hooks/useVaultForm'; import { handleError } from 'libs/errors'; import { useTranslation } from 'libs/translations'; import { useAccountAddress } from 'libs/wallet'; @@ -23,10 +24,9 @@ import { useEffect, useState } from 'react'; import type { Token } from 'types'; import { convertMantissaToTokens } from 'utilities'; import { formatTokensToReadableValue } from 'utilities/formatTokensToReadableValue'; -import type { FormValues } from '../useForm'; import { getPendleQuoteValidationMessage } from './getPendleQuoteValidationMessage'; -export interface TransactionFormProps { +export interface VaultFormProps { form: UseFormReturn; fromToken: Token; limitFromTokens: BigNumber; @@ -46,7 +46,7 @@ export interface TransactionFormProps { acknowledgement?: React.ReactNode; } -export const TransactionForm: React.FC = ({ +export const VaultForm: React.FC = ({ fromToken, fromTokenPriceCents, form, 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 74% rename from apps/evm/src/containers/VaultCard/VenusVaultModal/StakeForm/index.tsx rename to apps/evm/src/containers/VenusVaultModal/StakeForm/index.tsx index 0bed53d3eb..021a909041 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 { useForm } from 'containers/VaultCard/useForm'; +import { Footer as PrimeRankFooter } from 'containers/PrimeRank/Footer'; +import { VaultForm } from 'containers/VaultForm'; import { useGetContractAddress } from 'hooks/useGetContractAddress'; +import { useIsFeatureEnabled } from 'hooks/useIsFeatureEnabled'; import useTokenApproval from 'hooks/useTokenApproval'; +import { useVaultForm } from 'hooks/useVaultForm'; 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; @@ -60,7 +71,7 @@ export const StakeForm: React.FC = ({ vault, onClose }) => { const limitFromTokens = userWalletBalanceTokens; - const form = useForm({ + const form = useVaultForm({ limitFromTokens, walletSpendingLimitTokens, fromToken, @@ -92,7 +103,7 @@ export const StakeForm: React.FC = ({ vault, onClose }) => { }; return ( - = ({ vault, onClose }) => { fromTokenFieldLabel={t('vaultCard.vaultModal.stakeForm.depositField.label')} submitButtonLabel={t('vaultCard.vaultModal.stakeForm.submitButton.label')} fromTokenPriceCents={vault.stakedTokenPriceCents.toNumber()} - footer={