Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add claim button to header #2472

Merged
merged 6 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BigNumber } from 'ethers'
import SafeTokenWidget from '..'
import { hexZeroPad } from 'ethers/lib/utils'
import { AppRoutes } from '@/config/routes'
import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation'
import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation'

const MOCK_GOVERNANCE_APP_URL = 'https://mock.governance.safe.global'

Expand Down Expand Up @@ -52,21 +52,24 @@ describe('SafeTokenWidget', () => {

it('Should render nothing for unsupported chains', () => {
;(useChainId as jest.Mock).mockImplementationOnce(jest.fn(() => '100'))
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false])
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false])
;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false])

const result = render(<SafeTokenWidget />)
expect(result.baseElement).toContainHTML('<body><div /></body>')
})

it('Should display 0 if Safe has no SAFE token', async () => {
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false])
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false])
;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false])

const result = render(<SafeTokenWidget />)
await waitFor(() => expect(result.baseElement).toHaveTextContent('0'))
})

it('Should display the value formatted correctly', async () => {
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), false])
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false])
;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), , false])

// to avoid failing tests in some environments
const NumberFormat = Intl.NumberFormat
Expand All @@ -82,7 +85,8 @@ describe('SafeTokenWidget', () => {
})

it('Should render a link to the governance app', async () => {
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(420000), false])
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false])
;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false])

const result = render(<SafeTokenWidget />)
await waitFor(() => {
Expand All @@ -91,4 +95,14 @@ describe('SafeTokenWidget', () => {
)
})
})

it('Should render a claim button for SEP5 qualification', async () => {
;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[{ tag: 'user_v2' }], , false])
;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false])

const result = render(<SafeTokenWidget />)
await waitFor(() => {
expect(result.baseElement).toContainHTML('New allocation')
})
})
})
64 changes: 56 additions & 8 deletions src/components/common/SafeTokenWidget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,46 @@ import { SafeAppsTag, SAFE_TOKEN_ADDRESSES } from '@/config/constants'
import { AppRoutes } from '@/config/routes'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import useChainId from '@/hooks/useChainId'
import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation'
import useSafeTokenAllocation, { useSafeVotingPower, type Vesting } from '@/hooks/useSafeTokenAllocation'
import { OVERVIEW_EVENTS } from '@/services/analytics'
import { formatVisualAmount } from '@/utils/formatters'
import { Box, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material'
import { Box, Button, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material'
import { BigNumber } from 'ethers'
import Link from 'next/link'
import { useRouter } from 'next/router'
import type { UrlObject } from 'url'
import Track from '../Track'
import SafeTokenIcon from '@/public/images/common/safe-token.svg'
import css from './styles.module.css'
import UnreadBadge from '../UnreadBadge'
import classnames from 'classnames'

const TOKEN_DECIMALS = 18

export const getSafeTokenAddress = (chainId: string): string => {
return SAFE_TOKEN_ADDRESSES[chainId]
}

const canRedeemSep5Airdrop = (allocation?: Vesting[]): boolean => {
const sep5Allocation = allocation?.find(({ tag }) => tag === 'user_v2')

if (!sep5Allocation) {
return false
}

return !sep5Allocation.isRedeemed && !sep5Allocation.isExpired
}

const SEP5_DEADLINE = '27.10'

const SafeTokenWidget = () => {
const chainId = useChainId()
const router = useRouter()
const [apps] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP)
const governanceApp = apps?.[0]

const [allocation, allocationLoading] = useSafeTokenAllocation()
const [allocationData, , allocationDataLoading] = useSafeTokenAllocation()
const [allocation, , allocationLoading] = useSafeVotingPower(allocationData)

const tokenAddress = getSafeTokenAddress(chainId)
if (!tokenAddress) {
Expand All @@ -40,24 +55,57 @@ const SafeTokenWidget = () => {
}
: undefined

const canRedeemSep5 = canRedeemSep5Airdrop(allocationData)
const flooredSafeBalance = formatVisualAmount(allocation || BigNumber.from(0), TOKEN_DECIMALS, 2)

return (
<Box className={css.buttonContainer}>
<Tooltip title={url ? `Open ${governanceApp?.name}` : ''}>
<Tooltip
title={
url
? canRedeemSep5
? `Claim any amount until ${SEP5_DEADLINE} to be eligible!`
: `Open ${governanceApp?.name}`
: ''
}
>
<span>
<Track {...OVERVIEW_EVENTS.SAFE_TOKEN_WIDGET}>
<Link href={url || ''} passHref legacyBehavior>
<ButtonBase
aria-describedby="safe-token-widget"
sx={{ alignSelf: 'stretch' }}
className={css.tokenButton}
className={classnames(css.tokenButton, { [css.sep5]: canRedeemSep5 })}
disabled={url === undefined}
>
<SafeTokenIcon width={24} height={24} />
<Typography component="div" lineHeight="16px" fontWeight={700}>
{allocationLoading ? <Skeleton width="16px" animation="wave" /> : flooredSafeBalance}
<Typography
component="div"
lineHeight="16px"
fontWeight={700}
// Badge does not accept className so must be here
className={css.allocationBadge}
>
<UnreadBadge
invisible={!canRedeemSep5}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
>
{allocationDataLoading || allocationLoading ? (
<Skeleton width="16px" animation="wave" />
) : (
flooredSafeBalance
)}
</UnreadBadge>
</Typography>
{canRedeemSep5 && (
<Track {...OVERVIEW_EVENTS.SEP5_ALLOCATION_BUTTON}>
<Button variant="contained" className={css.redeemButton}>
New allocation
</Button>
</Track>
)}
</ButtonBase>
</Link>
</Track>
Expand Down
14 changes: 14 additions & 0 deletions src/components/common/SafeTokenWidget/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,18 @@
gap: var(--space-1);
margin-left: 0;
margin-right: 0;
align-self: stretch;
}

.sep5 {
height: 42px;
}

[data-theme='dark'] .allocationBadge :global .MuiBadge-dot {
background-color: var(--color-primary-main);
}

.redeemButton {
margin-left: var(--space-1);
padding: calc(var(--space-1) / 2) var(--space-1);
}
Loading