diff --git a/.changeset/happy-garlics-argue.md b/.changeset/happy-garlics-argue.md new file mode 100644 index 0000000000..d833fc25a6 --- /dev/null +++ b/.changeset/happy-garlics-argue.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": minor +--- + +feat: support user rank card diff --git a/.changeset/wise-toes-thank.md b/.changeset/wise-toes-thank.md new file mode 100644 index 0000000000..10724a30a8 --- /dev/null +++ b/.changeset/wise-toes-thank.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": minor +--- + +feat: add Prime total rewards card diff --git a/.gitignore b/.gitignore index 95193f1fef..fc73fb49db 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ Thumbs.db # AI development artifacts .ui-develop + +# local file +*.local +*.local.* 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/dotShortcut.tsx b/apps/evm/src/components/Icon/icons/dotShortcut.tsx new file mode 100644 index 0000000000..18888b09b5 --- /dev/null +++ b/apps/evm/src/components/Icon/icons/dotShortcut.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react'; + +const SvgDotShortcut = (props: SVGProps) => ( + + + + + + +); + +export default SvgDotShortcut; 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 27c0ae09a3..6029313a50 100644 --- a/apps/evm/src/components/Icon/icons/index.ts +++ b/apps/evm/src/components/Icon/icons/index.ts @@ -39,6 +39,7 @@ export { default as checkInlineDotted } from './checkInlineDotted'; export { default as mark } from './mark'; export { default as arrowShaft } from './arrowShaft'; export { default as notice } from './notice'; +export { default as dotShortcut } from './dotShortcut'; export { default as dots } from './dots'; export { default as exclamation } from './exclamation'; export { default as comment } from './comment'; @@ -66,6 +67,7 @@ export { default as shield2 } from './shield2'; export { default as lightning2 } from './lightning2'; export { default as graph } from './graph'; export { default as star } from './star'; +export { default as sparkle } from './sparkle'; export { default as download } from './download'; export { default as arrowUpFull2 } from './arrowUpFull2'; export { default as transactionFile } from './transactionFile'; @@ -99,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/components/Icon/icons/sparkle.tsx b/apps/evm/src/components/Icon/icons/sparkle.tsx new file mode 100644 index 0000000000..7e37b3a109 --- /dev/null +++ b/apps/evm/src/components/Icon/icons/sparkle.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +const SvgSparkle = (props: SVGProps) => ( + + + +); + +export default SvgSparkle; 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 9999974118..7c5776d874 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={