StakeCooldownModalContent.tsx
1 import { ChainId, Stake } from '@aave/contract-helpers'; 2 import { valueToBigNumber } from '@aave/math-utils'; 3 import { ArrowDownIcon, CalendarIcon } from '@heroicons/react/outline'; 4 import { ArrowNarrowRightIcon } from '@heroicons/react/solid'; 5 import { Trans } from '@lingui/macro'; 6 import { Box, Checkbox, FormControlLabel, SvgIcon, Typography } from '@mui/material'; 7 import dayjs from 'dayjs'; 8 import { formatEther, parseUnits } from 'ethers/lib/utils'; 9 import React, { useState } from 'react'; 10 import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; 11 import { TokenIcon } from 'src/components/primitives/TokenIcon'; 12 import { Warning } from 'src/components/primitives/Warning'; 13 import { useGeneralStakeUiData } from 'src/hooks/stake/useGeneralStakeUiData'; 14 import { useUserStakeUiData } from 'src/hooks/stake/useUserStakeUiData'; 15 import { useModalContext } from 'src/hooks/useModal'; 16 import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; 17 import { useRootStore } from 'src/store/root'; 18 import { stakeConfig } from 'src/ui-config/stakeConfig'; 19 import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; 20 import { GENERAL } from 'src/utils/mixPanelEvents'; 21 22 import { formattedTime, timeText } from '../../../helpers/timeHelper'; 23 import { Link } from '../../primitives/Link'; 24 import { TxErrorView } from '../FlowCommons/Error'; 25 import { GasEstimationError } from '../FlowCommons/GasEstimationError'; 26 import { TxSuccessView } from '../FlowCommons/Success'; 27 import { TxModalTitle } from '../FlowCommons/TxModalTitle'; 28 import { GasStation } from '../GasStation/GasStation'; 29 import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; 30 import { StakeCooldownActions } from './StakeCooldownActions'; 31 32 export type StakeCooldownProps = { 33 stakeAssetName: Stake; 34 icon: string; 35 }; 36 37 export enum ErrorType { 38 NOT_ENOUGH_BALANCE, 39 ALREADY_ON_COOLDOWN, 40 } 41 42 type CalendarEvent = { 43 title: string; 44 start: string; 45 end: string; 46 description: string; 47 }; 48 49 export const StakeCooldownModalContent = ({ stakeAssetName, icon }: StakeCooldownProps) => { 50 const { chainId: connectedChainId, readOnlyModeAddress } = useWeb3Context(); 51 const { gasLimit, mainTxState: txState, txError } = useModalContext(); 52 const trackEvent = useRootStore((store) => store.trackEvent); 53 const currentMarketData = useRootStore((store) => store.currentMarketData); 54 const currentNetworkConfig = useRootStore((store) => store.currentNetworkConfig); 55 const currentChainId = useRootStore((store) => store.currentChainId); 56 57 const { data: stakeUserResult } = useUserStakeUiData(currentMarketData, stakeAssetName); 58 const { data: stakeGeneralResult } = useGeneralStakeUiData(currentMarketData, stakeAssetName); 59 60 // states 61 const [cooldownCheck, setCooldownCheck] = useState(false); 62 63 const stakeData = stakeGeneralResult?.[0]; 64 const stakeUserData = stakeUserResult?.[0]; 65 66 // Cooldown logic 67 const stakeCooldownSeconds = stakeData?.stakeCooldownSeconds || 0; 68 const stakeUnstakeWindow = stakeData?.stakeUnstakeWindow || 0; 69 70 const cooldownPercent = valueToBigNumber(stakeCooldownSeconds) 71 .dividedBy(stakeCooldownSeconds + stakeUnstakeWindow) 72 .multipliedBy(100) 73 .toNumber(); 74 const unstakeWindowPercent = valueToBigNumber(stakeUnstakeWindow) 75 .dividedBy(stakeCooldownSeconds + stakeUnstakeWindow) 76 .multipliedBy(100) 77 .toNumber(); 78 79 const cooldownLineWidth = cooldownPercent < 15 ? 15 : cooldownPercent > 85 ? 85 : cooldownPercent; 80 const unstakeWindowLineWidth = 81 unstakeWindowPercent < 15 ? 15 : unstakeWindowPercent > 85 ? 85 : unstakeWindowPercent; 82 83 const stakedAmount = stakeUserData?.stakeTokenRedeemableAmount; 84 85 // error handler 86 let blockingError: ErrorType | undefined = undefined; 87 if (stakedAmount === '0') { 88 blockingError = ErrorType.NOT_ENOUGH_BALANCE; 89 } 90 91 const handleBlocked = () => { 92 switch (blockingError) { 93 case ErrorType.NOT_ENOUGH_BALANCE: 94 return <Trans>Nothing staked</Trans>; 95 default: 96 return null; 97 } 98 }; 99 100 // is Network mismatched 101 const stakingChain = 102 currentNetworkConfig.isFork && currentNetworkConfig.underlyingChainId === stakeConfig.chainId 103 ? currentChainId 104 : stakeConfig.chainId; 105 const isWrongNetwork = connectedChainId !== stakingChain; 106 107 const networkConfig = getNetworkConfig(stakingChain); 108 109 if (txError && txError.blocking) { 110 return <TxErrorView txError={txError} />; 111 } 112 if (txState.success) return <TxSuccessView action={<Trans>Stake cooldown activated</Trans>} />; 113 114 const timeMessage = (time: number) => { 115 return `${formattedTime(time)} ${timeText(time)}`; 116 }; 117 118 const handleOnCoolDownCheckBox = () => { 119 trackEvent(GENERAL.ACCEPT_RISK, { 120 asset: stakeAssetName, 121 modal: 'Cooldown', 122 }); 123 setCooldownCheck(!cooldownCheck); 124 }; 125 const amountToCooldown = formatEther(stakeUserData?.stakeTokenRedeemableAmount || 0); 126 127 const dateMessage = (time: number) => { 128 const now = dayjs(); 129 130 const futureDate = now.add(time, 'second'); 131 132 return futureDate.format('DD.MM.YY'); 133 }; 134 135 const googleDate = (timeInSeconds: number) => { 136 const date = dayjs().add(timeInSeconds, 'second'); 137 return date.format('YYYYMMDDTHHmmss') + 'Z'; // UTC time 138 }; 139 140 const createGoogleCalendarUrl = (event: CalendarEvent) => { 141 const startTime = encodeURIComponent(event.start); 142 const endTime = encodeURIComponent(event.end); 143 const text = encodeURIComponent(event.title); 144 const details = encodeURIComponent(event.description); 145 146 return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${text}&dates=${startTime}/${endTime}&details=${details}`; 147 }; 148 149 const event = { 150 title: 'Unstaking window for Aave', 151 start: googleDate(stakeCooldownSeconds), 152 end: googleDate(stakeCooldownSeconds + stakeUnstakeWindow), 153 description: 'Unstaking window for Aave staking activated', 154 }; 155 156 const googleCalendarUrl = createGoogleCalendarUrl(event); 157 158 return ( 159 <> 160 <TxModalTitle title="Cooldown to unstake" /> 161 {isWrongNetwork && !readOnlyModeAddress && ( 162 <ChangeNetworkWarning networkName={networkConfig.name} chainId={stakingChain} /> 163 )} 164 <Typography variant="description" sx={{ mb: 6 }}> 165 <Trans> 166 The cooldown period is {timeMessage(stakeCooldownSeconds)}. After{' '} 167 {timeMessage(stakeCooldownSeconds)} of cooldown, you will enter unstake window of{' '} 168 {timeMessage(stakeUnstakeWindow)}. You will continue receiving rewards during cooldown and 169 unstake window. 170 </Trans>{' '} 171 <Link 172 onClick={() => 173 trackEvent(GENERAL.EXTERNAL_LINK, { 174 assetName: 'ABPT', 175 link: 'Cooldown Learn More', 176 }) 177 } 178 variant="description" 179 href="https://docs.aave.com/faq/migration-and-staking" 180 sx={{ textDecoration: 'underline' }} 181 > 182 <Trans>Learn more</Trans> 183 </Link> 184 . 185 </Typography> 186 187 <Box 188 sx={{ 189 display: 'flex', 190 flexDirection: 'row', 191 width: '100%', 192 justifyContent: 'space-between', 193 pt: '6px', 194 pb: '30px', 195 }} 196 > 197 <Typography variant="description" color="text.primary"> 198 <Trans>Amount to unstake</Trans> 199 </Typography> 200 <Box sx={{ display: 'flex', alignItems: 'center' }}> 201 <TokenIcon symbol={icon} sx={{ mr: 1, width: 14, height: 14 }} /> 202 <FormattedNumber value={amountToCooldown} variant="secondary14" color="text.primary" /> 203 </Box> 204 </Box> 205 206 <Box 207 sx={{ 208 display: 'flex', 209 flexDirection: 'row', 210 width: '100%', 211 justifyContent: 'space-between', 212 pt: '6px', 213 pb: '30px', 214 }} 215 > 216 <Typography variant="description" color="text.primary"> 217 <Trans>Unstake window</Trans> 218 </Typography> 219 <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> 220 <Box sx={{ display: 'flex', alignItems: 'center' }}> 221 <Typography variant="secondary14" component="span"> 222 {dateMessage(stakeCooldownSeconds)} 223 </Typography> 224 <SvgIcon sx={{ fontSize: '13px', mx: 1 }}> 225 <ArrowNarrowRightIcon /> 226 </SvgIcon> 227 <Typography variant="secondary14" component="span"> 228 {dateMessage(stakeCooldownSeconds + stakeUnstakeWindow)} 229 </Typography> 230 </Box> 231 <Link 232 href={googleCalendarUrl} 233 target="_blank" 234 rel="noopener noreferrer" 235 sx={{ display: 'flex', alignItems: 'center', mt: 1 }} 236 > 237 <Trans>Remind me</Trans> 238 <SvgIcon sx={{ fontSize: '16px', ml: 1 }}> 239 <CalendarIcon /> 240 </SvgIcon> 241 </Link> 242 </Box> 243 </Box> 244 245 <Box mb={6}> 246 <Box 247 sx={{ 248 width: `${unstakeWindowLineWidth}%`, 249 display: 'flex', 250 alignItems: 'center', 251 justifyContent: 'center', 252 textAlign: 'center', 253 flexDirection: 'column', 254 ml: 'auto', 255 }} 256 > 257 <Typography variant="helperText" mb={1}> 258 <Trans>You unstake here</Trans> 259 </Typography> 260 <SvgIcon sx={{ fontSize: '13px' }}> 261 <ArrowDownIcon /> 262 </SvgIcon> 263 </Box> 264 265 <Box sx={{ display: 'flex', alignItems: 'center', my: 3 }}> 266 <Box 267 sx={{ 268 height: '2px', 269 width: `${cooldownLineWidth}%`, 270 bgcolor: 'error.main', 271 position: 'relative', 272 '&:after': { 273 content: "''", 274 position: 'absolute', 275 left: 0, 276 top: '50%', 277 transform: 'translateY(-50%)', 278 bgcolor: 'error.main', 279 width: '2px', 280 height: '8px', 281 borderRadius: '2px', 282 }, 283 }} 284 /> 285 <Box 286 sx={{ 287 height: '2px', 288 width: `${unstakeWindowLineWidth}%`, 289 bgcolor: 'success.main', 290 position: 'relative', 291 '&:after, &:before': { 292 content: "''", 293 position: 'absolute', 294 top: '50%', 295 transform: 'translateY(-50%)', 296 bgcolor: 'success.main', 297 width: '2px', 298 height: '8px', 299 borderRadius: '2px', 300 }, 301 '&:before': { 302 left: 0, 303 }, 304 '&:after': { 305 right: 0, 306 }, 307 }} 308 /> 309 </Box> 310 <Box sx={{ display: 'flex', justifyContent: 'space-between' }}> 311 <Box> 312 <Typography variant="helperText" mb={1}> 313 <Trans>Cooldown period</Trans> 314 </Typography> 315 <Typography variant="subheader2" color="error.main"> 316 <Trans>{timeMessage(stakeCooldownSeconds)}</Trans> 317 </Typography> 318 </Box> 319 <Box sx={{ textAlign: 'right' }}> 320 <Typography variant="helperText" mb={1}> 321 <Trans>Unstake window</Trans> 322 </Typography> 323 <Typography variant="subheader2" color="success.main"> 324 <Trans>{timeMessage(stakeUnstakeWindow)}</Trans> 325 </Typography> 326 </Box> 327 </Box> 328 </Box> 329 330 {blockingError !== undefined && ( 331 <Typography variant="helperText" color="red"> 332 {handleBlocked()} 333 </Typography> 334 )} 335 336 <Warning severity="error"> 337 <Typography variant="caption"> 338 <Trans> 339 If you DO NOT unstake within {timeMessage(stakeUnstakeWindow)} of unstake window, you 340 will need to activate cooldown process again. 341 </Trans> 342 </Typography> 343 </Warning> 344 345 <GasStation chainId={ChainId.mainnet} gasLimit={parseUnits(gasLimit || '0', 'wei')} /> 346 347 <FormControlLabel 348 sx={{ mt: 12 }} 349 control={ 350 <Checkbox 351 checked={cooldownCheck} 352 onClick={handleOnCoolDownCheckBox} 353 inputProps={{ 'aria-label': 'controlled' }} 354 data-cy={`cooldownAcceptCheckbox`} 355 /> 356 } 357 label={ 358 <Trans> 359 I understand how cooldown ({timeMessage(stakeCooldownSeconds)}) and unstaking ( 360 {timeMessage(stakeUnstakeWindow)}) work 361 </Trans> 362 } 363 /> 364 365 {txError && <GasEstimationError txError={txError} />} 366 367 <StakeCooldownActions 368 sx={{ mt: '48px' }} 369 isWrongNetwork={isWrongNetwork} 370 blocked={blockingError !== undefined || !cooldownCheck} 371 selectedToken={stakeAssetName} 372 amountToCooldown={amountToCooldown} 373 /> 374 </> 375 ); 376 };