import { Add, Check, CopyAll, ExpandCircleDownOutlined, } from '@mui/icons-material' import clsx from 'clsx' import { ComponentType, useEffect, useMemo, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import TimeAgo from 'react-timeago' import { HugeDecimal } from '@dao-dao/math' import { ButtonLinkProps, ButtonPopupSection, Entity, EntityType, GenericToken, LoadingData, StatefulEntityDisplayProps, TokenCardLazyInfo, UnstakingTaskStatus, VestingStep, } from '@dao-dao/types' import { abbreviateString, formatDateTimeTz, isNativeIbcUsdc, secondsToWdhms, } from '@dao-dao/utils' import { useTranslatedTimeDeltaFormatter } from '../../hooks' import { Button } from '../buttons' import { ChartEmoji, DepositEmoji, MoneyEmoji } from '../emoji' import { Loader } from '../logo' import { MarkdownRenderer } from '../MarkdownRenderer' import { ButtonPopup } from '../popup' import { ProfileImage } from '../profile' import { TokenAmountDisplay, UnstakingModal } from '../token' import { Tooltip, TooltipInfoIcon } from '../tooltip' import { VestingStepsLineGraph } from './VestingStepsLineGraph' export type VestingPaymentCardProps = { recipient: string recipientEntity: LoadingData // If current wallet connected is the recipient. recipientIsWallet: boolean EntityDisplay: ComponentType ButtonLink: ComponentType lazyInfo: LoadingData token: GenericToken title: string | undefined | null description: string | undefined | null remainingBalanceVesting: HugeDecimal distributableAmount: HugeDecimal claimedAmount: HugeDecimal startDate: Date endDate: Date steps: VestingStep[] canceled: boolean // Defined if using a Cw20 token. cw20Address?: string /** * Whether or not a wallet is connected. */ isWalletConnected: boolean onWithdraw: () => void withdrawing: boolean canClaimStakingRewards?: boolean onClaim?: () => void claiming?: boolean onManageStake?: () => void onAddToken?: () => void refreshUnstakingTasks?: () => void } export const VestingPaymentCard = ({ recipient, recipientEntity, recipientIsWallet, EntityDisplay, ButtonLink, lazyInfo, token, title, description, remainingBalanceVesting, distributableAmount, claimedAmount, startDate, endDate, steps, canceled, cw20Address, isWalletConnected, onWithdraw, withdrawing, canClaimStakingRewards, onClaim, claiming, onManageStake, onAddToken, refreshUnstakingTasks, }: VestingPaymentCardProps) => { const { t } = useTranslation() const lazyStakes = lazyInfo.loading || !lazyInfo.data.stakingInfo ? [] : lazyInfo.data.stakingInfo.stakes const lazyUnstakingTasks = lazyInfo.loading || !lazyInfo.data.stakingInfo ? [] : lazyInfo.data.stakingInfo.unstakingTasks const totalStaked = lazyStakes.reduce( (acc, stake) => acc.plus(stake.amount), HugeDecimal.zero ) const pendingRewards = lazyStakes?.reduce( (acc, stake) => acc.plus(stake.rewards), HugeDecimal.zero ) const unstakingBalance = lazyUnstakingTasks.reduce( (acc, task) => acc.plus( // Only include balance of unstaking tasks. task.status === UnstakingTaskStatus.Unstaking ? task.amount : HugeDecimal.zero ), HugeDecimal.zero ) const [showUnstakingTokens, setShowUnstakingTokens] = useState(false) const [copied, setCopied] = useState(false) // Debounce clearing copied. useEffect(() => { const timeout = setTimeout(() => setCopied(false), 2000) return () => clearTimeout(timeout) }, [copied]) const recipientIsDao = !recipientEntity.loading && recipientEntity.data.type === EntityType.Dao // Can only withdraw if there is a distributable amount and the recipient is // the currently connected wallet or is a DAO. const canWithdraw = isWalletConnected && (recipientIsWallet || recipientIsDao) && distributableAmount.isPositive() const buttonPopupSections: ButtonPopupSection[] = useMemo( () => [ // Only show payout actions if recipient is the currently connected // wallet or the recipient is a DAO. ...((recipientIsWallet || recipientIsDao) && (canWithdraw || onManageStake || (onClaim && canClaimStakingRewards)) ? [ { label: recipientIsDao ? t('title.propose') + '...' : t('title.manage'), buttons: [ ...(canWithdraw ? [ { Icon: MoneyEmoji, label: t('button.withdrawAvailableBalance'), closeOnClick: false, onClick: onWithdraw, loading: withdrawing, }, ] : []), ...(onManageStake ? [ { Icon: ChartEmoji, label: t('button.manageStaking'), closeOnClick: true, onClick: onManageStake, }, ] : []), ...(onClaim && canClaimStakingRewards ? [ { Icon: DepositEmoji, label: t('button.claimStakingRewards'), closeOnClick: false, onClick: onClaim, loading: claiming, }, ] : []), ], }, ] : []), ...(cw20Address || onAddToken ? [ { label: t('title.token'), buttons: [ ...(cw20Address ? [ { Icon: copied ? Check : CopyAll, label: t('button.copyAddressToClipboard'), closeOnClick: false, onClick: () => { if (!cw20Address) { return } navigator.clipboard.writeText(cw20Address) toast.success(t('info.copiedToClipboard')) setCopied(true) }, }, ] : []), ...(onAddToken ? [ { Icon: Add, label: t('button.addToKeplr'), closeOnClick: false, onClick: onAddToken, }, ] : []), ], }, ] : []), ], [ recipientIsWallet, recipientIsDao, canWithdraw, onManageStake, onClaim, canClaimStakingRewards, t, onWithdraw, withdrawing, claiming, cw20Address, onAddToken, copied, ] ) // Truncate IBC denominations to prevent overflow. if (token.symbol.toLowerCase().startsWith('ibc')) { token = { ...token, symbol: abbreviateString(token.symbol, 3, 2), } } const [descriptionCollapsible, setDescriptionCollapsible] = useState(false) const [descriptionCollapsed, setDescriptionCollapsed] = useState(true) const now = new Date() const startTimeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: true, futureMode: 'in', }) const endTimeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: true, futureMode: 'left', }) return ( <>
{/* Image */} {recipientEntity.loading ? ( ) : ( )} {/* Titles */}
{!!title &&

{title}

}
{buttonPopupSections.length > 0 && (
)}
{!!description && (
{ if (!ref || descriptionCollapsible) { return } const descriptionPTag = ref?.children[1]?.children[0] const descriptionOverflowing = !!descriptionPTag && descriptionPTag.scrollHeight > descriptionPTag.clientHeight setDescriptionCollapsible(descriptionOverflowing) } } >

{t('title.description')}

{(descriptionCollapsible || !descriptionCollapsed) && ( )}
)}

{endDate > now ? t('title.start') : t('info.startedAt')}

{/* leading-5 to match link-text's line-height. */} {endDate > now ? (

) : (

{formatDateTimeTz(startDate)}

)}
{canceled ? (

{t('title.canceled')}

) : (

{endDate > now ? t('title.timeRemaining') : t('info.finishedAt')}

{/* leading-5 to match link-text's line-height. */} {endDate > now ? (

) : (

{formatDateTimeTz(endDate)}

)}
)}
{/* Show available balance to withdraw if it is nonzero OR if there is still a balance vesting. This ensures that it explicitly displays that there is no balance to withdraw when the vest is not yet over. There may not be any balance if all vested tokens are staked or still unstaking, and it might be confusing if this line remains hidden in that case. */} {(distributableAmount.isPositive() || remainingBalanceVesting.isPositive()) && (

{t('info.availableBalance')}

{/* leading-5 to match link-text's line-height. */}
{/* leading-5 to match link-text's line-height. */} {!isNativeIbcUsdc(token.chainId, token.denomOrAddress) && (lazyInfo.loading || lazyInfo.data.usdUnitPrice?.usdPrice) && (
)}
)} {remainingBalanceVesting.isPositive() && (

{t('info.remainingBalanceVesting')}

{/* leading-5 to match link-text's line-height. */}
{/* leading-5 to match link-text's line-height. */} {!isNativeIbcUsdc(token.chainId, token.denomOrAddress) && (lazyInfo.loading || lazyInfo.data.usdUnitPrice?.usdPrice) && (
)}
)}

{t('title.claimedBalance')}

{/* leading-5 to match link-text's line-height. */}
{/* leading-5 to match link-text's line-height. */}
{canWithdraw && ( )}
{!lazyInfo.loading && (!!lazyInfo.data.stakingInfo?.stakes?.length || !!lazyInfo.data.stakingInfo?.unstakingTasks?.length) && (

{t('info.stakes')}

{t('title.staked')}

{t('title.stakedTo')}

{lazyInfo.loading ? '...' : lazyStakes.length > 0 && ( <> {lazyStakes[0].validator.moniker} {lazyStakes.length > 1 && ( <> ,{' '} {lazyStakes .slice(1) .map(({ validator }, index) => (

{validator.moniker}

))} } > {t('info.andNumMore', { count: lazyStakes.length - 1, })} )} )}

{t('title.unstakingTokens')}

{t('info.pendingRewards')}

{onClaim && canClaimStakingRewards && ( )}
)} {!canceled && (
)}
{!lazyInfo.loading && lazyInfo.data.stakingInfo && ( setShowUnstakingTokens(false)} refresh={refreshUnstakingTasks} tasks={lazyInfo.data.stakingInfo.unstakingTasks} unstakingDuration={ lazyInfo.data.stakingInfo.unstakingDurationSeconds ? secondsToWdhms( lazyInfo.data.stakingInfo.unstakingDurationSeconds ) : undefined } visible={showUnstakingTokens} /> )} ) }