diff --git a/apps/web/src/pages/ido/index.tsx b/apps/web/src/pages/ido/index.tsx new file mode 100644 index 0000000000000..525f428cdf661 --- /dev/null +++ b/apps/web/src/pages/ido/index.tsx @@ -0,0 +1,15 @@ +import { ChainId } from '@pancakeswap/chains' +import IDo from '../../views/Idos/ido' +import { IfoPageLayout } from '../../views/Ifos' + +const IDO_SUPPORT_CHAINS = [ChainId.BSC] + +const CurrentIfoPage = () => { + return +} + +CurrentIfoPage.Layout = IfoPageLayout + +CurrentIfoPage.chains = IDO_SUPPORT_CHAINS + +export default CurrentIfoPage diff --git a/apps/web/src/views/Idos/CurrentIfo.tsx b/apps/web/src/views/Idos/CurrentIfo.tsx new file mode 100644 index 0000000000000..564c355d7f704 --- /dev/null +++ b/apps/web/src/views/Idos/CurrentIfo.tsx @@ -0,0 +1,79 @@ +import { Ifo, isCrossChainIfoSupportedOnly } from '@pancakeswap/ifos' +import { useMemo } from 'react' + +import { useFetchIfo } from 'state/pools/hooks' +import useGetPublicIfoV8Data from 'views/Ifos/hooks/v8/useGetPublicIfoData' +import useGetWalletIfoV8Data from 'views/Ifos/hooks/v8/useGetWalletIfoData' + +import IfoContainer from './components/IfoContainer' +import { IfoCurrentCard } from './components/IfoFoldableCard' +import IfoQuestions from './components/IfoQuestions' +import IfoSteps from './components/IfoSteps' +import { SectionBackground } from './components/SectionBackground' +import { useICakeBridgeStatus } from './hooks/useIfoCredit' +import { isBasicSale } from './hooks/v7/helpers' + +interface TypeProps { + activeIfo: Ifo +} + +const CurrentIfo: React.FC> = ({ activeIfo }) => { + useFetchIfo() + const publicIfoData = useGetPublicIfoV8Data(activeIfo) + const walletIfoData = useGetWalletIfoV8Data(activeIfo) + const { hasBridged, sourceChainCredit, srcChainId, destChainCredit } = useICakeBridgeStatus({ + ifoChainId: activeIfo.chainId, + ifoAddress: activeIfo.address, + }) + + const isCrossChainIfo = useMemo(() => isCrossChainIfoSupportedOnly(activeIfo.chainId), [activeIfo.chainId]) + + const { poolBasic, poolUnlimited } = walletIfoData + + const isCommitted = useMemo( + () => + poolBasic?.amountTokenCommittedInLP.isGreaterThan(0) || poolUnlimited.amountTokenCommittedInLP.isGreaterThan(0), + [poolBasic?.amountTokenCommittedInLP, poolUnlimited.amountTokenCommittedInLP], + ) + + const isBasicSaleOnly = useMemo( + () => isBasicSale(publicIfoData.poolBasic?.saleType) && publicIfoData.poolBasic?.distributionRatio === 1, + [publicIfoData.poolBasic?.saleType, publicIfoData.poolBasic?.distributionRatio], + ) + + const steps = isBasicSaleOnly ? null : ( + + ) + + const faq = isBasicSaleOnly ? ( + + + + ) : ( + + ) + + return ( + } + ifoSteps={steps} + faq={faq} + /> + ) +} + +export default CurrentIfo diff --git a/apps/web/src/views/Idos/IfoPlaceholder.tsx b/apps/web/src/views/Idos/IfoPlaceholder.tsx new file mode 100644 index 0000000000000..e495300bea44b --- /dev/null +++ b/apps/web/src/views/Idos/IfoPlaceholder.tsx @@ -0,0 +1,100 @@ +import { bscTokens } from '@pancakeswap/tokens' +import { useMemo } from 'react' +import { + Card, + IfoSkeletonCardTokens, + IfoSkeletonCardActions, + IfoSkeletonCardDetails, + Box, + IfoGenericIfoCard, +} from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import styled from 'styled-components' +import { PoolIds } from '@pancakeswap/ifos' + +import { useFetchIfo } from 'state/pools/hooks' + +import { CardsWrapper } from './components/IfoCardStyles' +import { StyledCardBody } from './components/IfoFoldableCard/index' +import IfoContainer from './components/IfoContainer' +import IfoSteps from './components/IfoSteps' +import { cardConfig } from './components/IfoFoldableCard/IfoPoolCard' + +const CurveBox = styled(Box)` + border-bottom-left-radius: 100% 40px; + border-bottom-right-radius: 100% 40px; + background-color: ${({ theme }) => theme.colors.backgroundDisabled}; +` + +function Placeholder() { + const { t } = useTranslation() + + const basicConfig = useMemo( + () => + cardConfig(t, PoolIds.poolBasic, { + version: 7, + }), + [t], + ) + + const unlimitedConfig = useMemo( + () => + cardConfig(t, PoolIds.poolUnlimited, { + version: 7, + }), + [t], + ) + + const skeletons = ( + + + + + + + + + + ) + + return ( + + + + + + + + + + ) +} + +export function IfoPlaceholder() { + useFetchIfo() + return ( + } + ifoSteps={ + + } + /> + ) +} diff --git a/apps/web/src/views/Idos/PastIfo.tsx b/apps/web/src/views/Idos/PastIfo.tsx new file mode 100644 index 0000000000000..5bc960a856ca3 --- /dev/null +++ b/apps/web/src/views/Idos/PastIfo.tsx @@ -0,0 +1,41 @@ +import { Container } from '@pancakeswap/uikit' + +import { useInActiveIfoConfigs } from 'hooks/useIfoConfig' + +import IfoCardV1Data from './components/IfoCardV1Data' +import IfoCardV2Data from './components/IfoCardV2Data' +import IfoCardV3Data from './components/IfoCardV3Data' +import { IfoCardV7Data } from './components/IfoCardV7Data' +import { IfoCardV8Data } from './components/IfoCardV8Data' +import IfoLayout from './components/IfoLayout' + +const PastIfo = () => { + const inactiveIfo = useInActiveIfoConfigs() + + return ( + + + {inactiveIfo?.map((ifo) => { + switch (ifo.version) { + case 1: + return + case 2: + return + case 3: + case 3.1: + case 3.2: + return + case 7: + return + case 8: + return + default: + return null + } + })} + + + ) +} + +export default PastIfo diff --git a/apps/web/src/views/Idos/SoonIfo.tsx b/apps/web/src/views/Idos/SoonIfo.tsx new file mode 100644 index 0000000000000..84f64c6cd8a5c --- /dev/null +++ b/apps/web/src/views/Idos/SoonIfo.tsx @@ -0,0 +1,33 @@ +import { bscTokens } from '@pancakeswap/tokens' + +import { useFetchIfo } from 'state/pools/hooks' +import { useActiveChainId } from 'hooks/useActiveChainId' + +import IfoContainer from './components/IfoContainer' +import IfoSteps from './components/IfoSteps' +import ComingSoonSection from './components/ComingSoonSection' +import { useICakeBridgeStatus } from './hooks/useIfoCredit' + +const SoonIfo = () => { + useFetchIfo() + const { chainId } = useActiveChainId() + const { sourceChainCredit } = useICakeBridgeStatus({ + ifoChainId: chainId, + }) + return ( + } + ifoSteps={ + + } + /> + ) +} + +export default SoonIfo diff --git a/apps/web/src/views/Idos/components/ComingSoonSection.tsx b/apps/web/src/views/Idos/components/ComingSoonSection.tsx new file mode 100644 index 0000000000000..76b43479efd24 --- /dev/null +++ b/apps/web/src/views/Idos/components/ComingSoonSection.tsx @@ -0,0 +1,88 @@ +import { useMemo } from 'react' +import { Card, Text, BunnyPlaceholderIcon, Box, IfoGenericIfoCard, BunnyKnownPlaceholder } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import styled from 'styled-components' +import { PoolIds } from '@pancakeswap/ifos' + +import { CardsWrapper } from './IfoCardStyles' +import { StyledCardBody } from './IfoFoldableCard/index' +import { cardConfig } from './IfoFoldableCard/IfoPoolCard' + +const CurveBox = styled(Box)` + border-bottom-left-radius: 100% 40px; + border-bottom-right-radius: 100% 40px; + background-repeat: no-repeat; + background-size: cover; + background-position: center center; +` + +export default function ComingSoonSection() { + const { t } = useTranslation() + + const basicConfig = useMemo( + () => + cardConfig(t, PoolIds.poolBasic, { + version: 3.1, + }), + [t], + ) + + const unlimitedConfig = useMemo( + () => + cardConfig(t, PoolIds.poolUnlimited, { + version: 3.1, + }), + [t], + ) + + return ( + + + + + + + + {t('Follow our social channels to learn more about the next IFO.')} + + + } + action={null} + /> + + + + {t('Follow our social channels to learn more about the next IFO.')} + + + } + action={null} + /> + + + + ) +} diff --git a/apps/web/src/views/Idos/components/CrossChainVeCakeCard.tsx b/apps/web/src/views/Idos/components/CrossChainVeCakeCard.tsx new file mode 100644 index 0000000000000..90be70d93e063 --- /dev/null +++ b/apps/web/src/views/Idos/components/CrossChainVeCakeCard.tsx @@ -0,0 +1,206 @@ +import { ChainId, isTestnetChainId } from '@pancakeswap/chains' +import { useTranslation } from '@pancakeswap/localization' +import { CAKE } from '@pancakeswap/tokens' +import { formatBigInt, getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { Ifo } from '@pancakeswap/widgets-internal' +import { useCallback, useMemo, useState } from 'react' +import { Address } from 'viem' +import { useAccount } from 'wagmi' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useCakePrice } from 'hooks/useCakePrice' + +// TODO should be common hooks +import { useIsMigratedToVeCake } from 'views/CakeStaking/hooks/useIsMigratedToVeCake' +import { useIsUserDelegated } from 'views/CakeStaking/hooks/useIsUserDelegated' +import { useCakeLockStatus } from 'views/CakeStaking/hooks/useVeCakeUserInfo' + +import { CAKE_VAULT_SUPPORTED_CHAINS } from '@pancakeswap/pools' +import { Flex, Text } from '@pancakeswap/uikit' +import { BigNumber as BN } from 'bignumber.js' +import ConnectWalletButton from 'components/ConnectWalletButton' +import { CrossChainVeCakeModal } from 'components/CrossChainVeCakeModal' +import { useUserVeCakeStatus } from 'components/CrossChainVeCakeModal/hooks/useUserVeCakeStatus' +import { useActiveIfoConfig } from 'hooks/useIfoConfig' +import { useVeCakeBalance } from 'hooks/useTokenBalance' +import { useRouter } from 'next/router' +import { logGTMIfoGoToCakeStakingEvent } from 'utils/customGTMEventTracking' +import { useChainNames } from '../hooks/useChainNames' +import { useUserIfoInfo } from '../hooks/useUserIfoInfo' +import { ICakeLogo } from './Icons' +import { NetworkSwitcherModal } from './IfoFoldableCard/IfoPoolCard/NetworkSwitcherModal' + +type Props = { + ifoAddress?: Address +} + +export function CrossChainVeCakeCard({ ifoAddress }: Props) { + const { t } = useTranslation() + const router = useRouter() + + const { chainId } = useActiveChainId() + const { isConnected } = useAccount() + + const { activeIfo } = useActiveIfoConfig() + + const targetChainId = useMemo(() => activeIfo?.chainId || chainId, [activeIfo, chainId]) + + const cakePrice = useCakePrice() + const isUserDelegated = useIsUserDelegated() + + const [isOpen, setIsOpen] = useState(false) + + // For "Switch Chain to Stake Cake" Modal + const [isNetworkModalOpen, setIsNetworkModalOpen] = useState(false) + const supportedChainIds = useMemo( + () => CAKE_VAULT_SUPPORTED_CHAINS.filter((vaultChainId) => !isTestnetChainId(vaultChainId)), + [], + ) + const cakeVaultChainNames = useChainNames(supportedChainIds) + + const { + cakeUnlockTime: nativeUnlockTime, + nativeCakeLockedAmount, + proxyCakeLockedAmount, + cakePoolLocked: proxyLocked, + cakePoolUnlockTime: proxyUnlockTime, + cakeLocked: nativeLocked, + shouldMigrate, + } = useCakeLockStatus(ChainId.BSC) + + const { balance: veCakeOnBSC } = useVeCakeBalance(ChainId.BSC) + const { balance: veCakeOnTargetChain } = useVeCakeBalance(ChainId.ARBITRUM_ONE) + + const veCakeOnBSCFormatted = useMemo(() => getBalanceNumber(veCakeOnBSC, CAKE[ChainId.BSC].decimals), [veCakeOnBSC]) + const veCakeOnTargetChainFormatted = useMemo( + () => getBalanceNumber(veCakeOnTargetChain, CAKE[targetChainId].decimals), + [veCakeOnTargetChain, targetChainId], + ) + + const hasVeCakeOnBSC = useMemo(() => veCakeOnBSC.gt(0), [veCakeOnBSC]) + + const { isSynced, isVeCakeSynced } = useUserVeCakeStatus(targetChainId) + + // To be synced for the first time + const toBeSynced = useMemo(() => veCakeOnBSC.gt(0) && veCakeOnTargetChain.eq(0), [veCakeOnBSC, veCakeOnTargetChain]) + + const isMigrated = useIsMigratedToVeCake(targetChainId) + const needMigrate = useMemo(() => shouldMigrate && !isMigrated, [shouldMigrate, isMigrated]) + const totalLockCake = useMemo( + () => + Number( + formatBigInt( + isUserDelegated ? nativeCakeLockedAmount : nativeCakeLockedAmount + proxyCakeLockedAmount, + CAKE[ChainId.BSC].decimals, + ), + ), + [nativeCakeLockedAmount, proxyCakeLockedAmount, isUserDelegated], + ) + const hasProxyCakeButNoNativeVeCake = useMemo(() => !nativeLocked && proxyLocked, [nativeLocked, proxyLocked]) + const unlockAt = useMemo(() => { + if (hasProxyCakeButNoNativeVeCake) { + return proxyUnlockTime + } + return nativeUnlockTime + }, [hasProxyCakeButNoNativeVeCake, nativeUnlockTime, proxyUnlockTime]) + + const { snapshotTime, credit, veCake, ratio } = useUserIfoInfo({ ifoAddress, chainId: targetChainId }) + + const creditBN = useMemo( + () => credit && new BN(credit.numerator.toString()).div(credit.decimalScale.toString()), + [credit], + ) + + const hasICake = useMemo(() => creditBN && creditBN.toNumber() > 0, [creditBN]) + const hasVeCake = useMemo(() => veCake && veCake.toNumber() > 0, [veCake]) + + const handleSwitchNetworkSuccess = useCallback(() => { + setIsNetworkModalOpen(false) + + router.push('/cake-staking') // See why this is not working in dev but works in preview links + }, [router, setIsNetworkModalOpen]) + + const header = ( + <> + + + + ) + + return ( + + + {isConnected && !hasVeCake ? ( + !needMigrate && hasProxyCakeButNoNativeVeCake && !isUserDelegated ? ( + + ) : null + ) : null} + {needMigrate ? : null} + + {isConnected && hasVeCakeOnBSC ? ( + + ) : ( + <> + { + setIsNetworkModalOpen(true) + logGTMIfoGoToCakeStakingEvent() + }} + ConnectWalletButton={} + /> + + + + + + {t('Stake CAKE to obtain iCAKE - in order to be eligible in this public sale.')} + + + + } + onDismiss={() => setIsNetworkModalOpen(false)} + onSwitchNetworkSuccess={handleSwitchNetworkSuccess} + /> + + )} + + {isConnected && hasVeCakeOnBSC && ( + <> + setIsOpen(true)} + /> + setIsOpen(false)} + /> + + )} + + + ) +} diff --git a/apps/web/src/views/Idos/components/Hero.tsx b/apps/web/src/views/Idos/components/Hero.tsx new file mode 100644 index 0000000000000..750da5e49c450 --- /dev/null +++ b/apps/web/src/views/Idos/components/Hero.tsx @@ -0,0 +1,133 @@ +import { isIfoSupported } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { ChainId } from '@pancakeswap/sdk' +import { Box, Button, Container, Flex, Heading, Text, useMatchBreakpoints } from '@pancakeswap/uikit' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { styled } from 'styled-components' + +import { getChainBasedImageUrl } from '../helpers' + +const StyledHero = styled(Box)` + position: relative; + overflow: hidden; + background: ${({ theme }) => + theme.isDark + ? 'linear-gradient(139.73deg, #313D5C 0%, #3D2A54 100%)' + : 'linear-gradient(139.73deg, #E6FDFF 0%, #F3EFFF 100%)'}; +` + +const BunnyContainer = styled(Box)` + z-index: 1; + position: absolute; + right: -20%; + bottom: -15%; + + ${({ theme }) => theme.mediaQueries.md} { + right: 10%; + bottom: 0; + } +` + +const StyledHeading = styled(Heading)` + font-size: 2.5rem; + color: ${({ theme }) => theme.colors.secondary}; + + ${({ theme }) => theme.mediaQueries.md} { + font-size: 4rem; + } +` + +const StyledButton = styled(Button)` + background-color: ${({ theme }) => theme.colors.tertiary}; + color: ${({ theme }) => theme.colors.primary}; + padding: 4px 13px; + height: auto; + text-transform: uppercase; + align-self: flex-start; + font-size: 12px; + box-shadow: ${({ theme }) => theme.shadows.inset}; + border-radius: 8px; +` + +const DesktopButton = styled(Button)` + align-self: flex-end; + + &:hover { + opacity: 1 !important; + } +` + +const StyledSubTitle = styled(Text)` + font-size: 16px; + + ${({ theme }) => theme.mediaQueries.md} { + font-size: 20px; + } +` + +const Hero = () => { + const router = useRouter() + const { t } = useTranslation() + const { isMobile } = useMatchBreakpoints() + + const handleClick = () => { + const howToElem = document.getElementById('ifo-how-to') + if (howToElem != null) { + howToElem.scrollIntoView() + } else { + router.push('/ifo#ifo-how-to') + } + } + + return ( + + + + + + + + {t('IFO: Initial Farm Offerings')} + + + {isMobile ? t('Buy new tokens using CAKE') : t('Buy new tokens launching on PancakeSwap using CAKE')} + + + {isMobile ? ( + + {t('How does it work?')} + + ) : ( + + {t('How does it work?')} + + )} + + + + + ) +} + +function HeaderBunny() { + const { chainId: currentChainId } = useActiveChainId() + const { isDesktop } = useMatchBreakpoints() + const bunnyImageUrl = useMemo(() => { + const chainId = isIfoSupported(currentChainId) ? currentChainId : ChainId.BSC + return getChainBasedImageUrl({ chainId, name: 'header-bunny' }) + }, [currentChainId]) + + return ( + + header-bunny + + ) +} + +export default Hero diff --git a/apps/web/src/views/Idos/components/Icons.tsx b/apps/web/src/views/Idos/components/Icons.tsx new file mode 100644 index 0000000000000..9479749e56a46 --- /dev/null +++ b/apps/web/src/views/Idos/components/Icons.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from 'react' +import { Box, ICakeIcon, LogoRoundIcon } from '@pancakeswap/uikit' +import { ChainLogo } from '@pancakeswap/widgets-internal' +import { ChainId } from '@pancakeswap/sdk' +import styled from 'styled-components' +import { SpaceProps } from 'styled-system' + +type Props = { + iconTop: ReactNode + iconBottom: ReactNode +} & SpaceProps + +const DoubleContainer = styled(Box)` + position: relative; + align-self: flex-start; +` + +const IconBottomContainer = styled(Box)` + position: relative; + z-index: 1; +` + +const IconTopContainer = styled(Box)` + position: absolute; + top: 50%; + left: 50%; + z-index: 2; +` + +export function DoubleIcon({ iconTop, iconBottom, ...props }: Props) { + return ( + + {iconBottom} + {iconTop} + + ) +} + +type IfoIconProps = { + chainId?: ChainId +} & SpaceProps + +export function IfoIcon({ chainId, ...props }: IfoIconProps) { + return ( + } + iconTop={} + {...props} + /> + ) +} + +export function ICakeLogo(props: SpaceProps) { + return ( + } + iconTop={} + {...props} + /> + ) +} diff --git a/apps/web/src/views/Idos/components/IfoCardStyles.tsx b/apps/web/src/views/Idos/components/IfoCardStyles.tsx new file mode 100644 index 0000000000000..1ab61e3f1dcfb --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardStyles.tsx @@ -0,0 +1,51 @@ +import { Card, StyledLink } from '@pancakeswap/uikit' +import { styled } from 'styled-components' +import NextLink from 'next/link' +import { TypographyProps, typography } from 'styled-system' + +export const StyledCard = styled(Card)` + background: none; + max-width: 368px; + width: 100%; + margin: 0 auto; + height: fit-content; +` + +export const CardsWrapper = styled.div<{ $singleCard?: boolean; $shouldReverse?: boolean }>` + display: grid; + grid-gap: 32px; + grid-template-columns: 1fr; + ${({ theme }) => theme.mediaQueries.xxl} { + grid-template-columns: ${({ $singleCard }) => ($singleCard ? '1fr' : '1fr 1fr')}; + justify-items: ${({ $singleCard }) => ($singleCard ? 'center' : 'unset')}; + } + + > div:nth-child(1) { + order: ${({ $shouldReverse }) => ($shouldReverse ? 2 : 1)}; + } + + > div:nth-child(2) { + order: ${({ $shouldReverse }) => ($shouldReverse ? 1 : 2)}; + } +` + +export const MessageTextLink = styled(StyledLink)` + display: inline; + text-decoration: underline; + font-weight: bold; + font-size: 14px; + white-space: nowrap; + cursor: pointer; +` + +export const TextLink = styled(NextLink)` + display: inline; + text-decoration: underline; + font-weight: bold; + font-size: 14px; + white-space: nowrap; + cursor: pointer; + color: ${({ theme }) => theme.colors.primary}; + + ${typography} +` diff --git a/apps/web/src/views/Idos/components/IfoCardV1Data.tsx b/apps/web/src/views/Idos/components/IfoCardV1Data.tsx new file mode 100644 index 0000000000000..14fa7e191ab11 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardV1Data.tsx @@ -0,0 +1,17 @@ +import useGetPublicIfoV1Data from 'views/Ifos/hooks/v1/useGetPublicIfoData' +import useGetWalletIfoV1Data from 'views/Ifos/hooks/v1/useGetWalletIfoData' +import { Ifo } from '@pancakeswap/ifos' +import IfoFoldableCard from './IfoFoldableCard' + +interface Props { + ifo: Ifo +} + +const IfoCardV1Data: React.FC> = ({ ifo }) => { + const publicIfoData = useGetPublicIfoV1Data(ifo) + const walletIfoData = useGetWalletIfoV1Data(ifo) + + return +} + +export default IfoCardV1Data diff --git a/apps/web/src/views/Idos/components/IfoCardV2Data.tsx b/apps/web/src/views/Idos/components/IfoCardV2Data.tsx new file mode 100644 index 0000000000000..2ac34c767dd83 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardV2Data.tsx @@ -0,0 +1,17 @@ +import useGetPublicIfoV2Data from 'views/Ifos/hooks/v2/useGetPublicIfoData' +import useGetWalletIfoV2Data from 'views/Ifos/hooks/v2/useGetWalletIfoData' +import { Ifo } from '@pancakeswap/ifos' +import IfoFoldableCard from './IfoFoldableCard' + +interface Props { + ifo: Ifo +} + +const IfoCardV2Data: React.FC> = ({ ifo }) => { + const publicIfoData = useGetPublicIfoV2Data(ifo) + const walletIfoData = useGetWalletIfoV2Data(ifo) + + return +} + +export default IfoCardV2Data diff --git a/apps/web/src/views/Idos/components/IfoCardV3Data.tsx b/apps/web/src/views/Idos/components/IfoCardV3Data.tsx new file mode 100644 index 0000000000000..bb1360f8a0e7b --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardV3Data.tsx @@ -0,0 +1,17 @@ +import useGetPublicIfoV3Data from 'views/Ifos/hooks/v3/useGetPublicIfoData' +import useGetWalletIfoV3Data from 'views/Ifos/hooks/v3/useGetWalletIfoData' +import { Ifo } from '@pancakeswap/ifos' +import IfoFoldableCard from './IfoFoldableCard' + +interface Props { + ifo: Ifo +} + +const IfoCardV3Data: React.FC> = ({ ifo }) => { + const publicIfoData = useGetPublicIfoV3Data(ifo) + const walletIfoData = useGetWalletIfoV3Data(ifo) + + return +} + +export default IfoCardV3Data diff --git a/apps/web/src/views/Idos/components/IfoCardV7Data.tsx b/apps/web/src/views/Idos/components/IfoCardV7Data.tsx new file mode 100644 index 0000000000000..12e96e2cb2d98 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardV7Data.tsx @@ -0,0 +1,17 @@ +import { Ifo } from '@pancakeswap/ifos' + +import useGetPublicIfoV7Data from 'views/Ifos/hooks/v7/useGetPublicIfoData' +import useGetWalletIfoV7Data from 'views/Ifos/hooks/v7/useGetWalletIfoData' + +import IfoFoldableCard from './IfoFoldableCard' + +interface Props { + ifo: Ifo +} + +export const IfoCardV7Data: React.FC> = ({ ifo }) => { + const publicIfoData = useGetPublicIfoV7Data(ifo) + const walletIfoData = useGetWalletIfoV7Data(ifo) + + return +} diff --git a/apps/web/src/views/Idos/components/IfoCardV8Data.tsx b/apps/web/src/views/Idos/components/IfoCardV8Data.tsx new file mode 100644 index 0000000000000..8e7cda27ad902 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoCardV8Data.tsx @@ -0,0 +1,17 @@ +import { Ifo } from '@pancakeswap/ifos' + +import useGetPublicIfoV8Data from 'views/Ifos/hooks/v8/useGetPublicIfoData' +import useGetWalletIfoV8Data from 'views/Ifos/hooks/v8/useGetWalletIfoData' + +import IfoFoldableCard from './IfoFoldableCard' + +interface Props { + ifo: Ifo +} + +export const IfoCardV8Data: React.FC> = ({ ifo }) => { + const publicIfoData = useGetPublicIfoV8Data(ifo) + const walletIfoData = useGetWalletIfoV8Data(ifo) + + return +} diff --git a/apps/web/src/views/Idos/components/IfoChainBoard.tsx b/apps/web/src/views/Idos/components/IfoChainBoard.tsx new file mode 100644 index 0000000000000..ca4a77e6ae31b --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoChainBoard.tsx @@ -0,0 +1,62 @@ +import { ChainId } from '@pancakeswap/chains' +import { memo, useMemo } from 'react' +import { Box, Text, useMatchBreakpoints } from '@pancakeswap/uikit' +import styled from 'styled-components' +import { useTranslation } from '@pancakeswap/localization' + +import { useChainName } from '../hooks/useChainNames' +import { getChainBasedImageUrl } from '../helpers' + +const BACKGROUND = { + [ChainId.POLYGON_ZKEVM]: 'linear-gradient(180deg, #9132D2 0%, #803DE1 100%)', + [ChainId.BSC]: '#D8A70A', + [ChainId.BSC_TESTNET]: '#D8A70A', + [ChainId.ETHEREUM]: '#627AD8', + [ChainId.GOERLI]: '#627AD8', + [ChainId.ARBITRUM_ONE]: '#2D364D', +} + +const Container = styled(Box)` + width: 100%; +` + +const Tag = styled(Box)` + position: absolute; + top: 0; + transform: translate(-50%, -50%); + white-space: nowrap; + padding: 0.25rem 0.75rem; + border-radius: 2.75rem; + + ${({ theme }) => theme.mediaQueries.sm} { + top: 2rem; + right: 1.625rem; + transform: translateX(100%); + } +` + +type Props = { + chainId?: ChainId +} + +export const IfoChainBoard = memo(function IfoChainBoard({ chainId }: Props) { + const { isMobile } = useMatchBreakpoints() + const { t } = useTranslation() + const boardImageUrl = useMemo(() => getChainBasedImageUrl({ chainId, name: 'chain-board' }), [chainId]) + const chainName = useChainName(chainId, { shortName: true }) + + if (!chainId) { + return null + } + + return ( + + {!isMobile && {`chain-${chainId}`}} + + + {t('On %chainName%', { chainName })} + + + + ) +}) diff --git a/apps/web/src/views/Idos/components/IfoContainer.tsx b/apps/web/src/views/Idos/components/IfoContainer.tsx new file mode 100644 index 0000000000000..f1bea0e6df50f --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoContainer.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from '@pancakeswap/localization' +import { Container, LinkExternal } from '@pancakeswap/uikit' +import { ReactNode } from 'react' +import { Address } from 'viem' + +import IfoLayout, { IfoLayoutWrapper } from './IfoLayout' +import { SectionBackground } from './SectionBackground' + +interface TypeProps { + ifoSection: ReactNode + ifoSteps: ReactNode + faq?: ReactNode + ifoBasicSaleType?: number + ifoAddress?: Address +} + +const IfoContainer: React.FC> = ({ ifoSection, ifoSteps, faq }) => { + const { t } = useTranslation() + + return ( + + + {ifoSection} + + + {ifoSteps} + + {faq} + + {t('Apply to run an IFO!')} + + + ) +} + +export default IfoContainer diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/Achievement.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/Achievement.tsx new file mode 100644 index 0000000000000..a9722397420aa --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/Achievement.tsx @@ -0,0 +1,217 @@ +import { ChainId } from '@pancakeswap/chains' +import { Ifo } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { bscTokens } from '@pancakeswap/tokens' +import { + Box, + Flex, + FlexGap, + Image, + LanguageIcon, + Link, + PrizeIcon, + Skeleton, + Svg, + SvgProps, + TelegramIcon, + Text, + TwitterIcon, +} from '@pancakeswap/uikit' +import { BIG_TEN } from '@pancakeswap/utils/bigNumber' +import { formatBigInt } from '@pancakeswap/utils/formatBalance' +import { ASSET_CDN } from 'config/constants/endpoints' +import { styled } from 'styled-components' +import { getBlockExploreLink } from 'utils' +import { PublicIfoData } from 'views/Ifos/types' + +const SmartContractIcon: React.FC> = (props) => { + return ( + + + + + + ) +} + +const ProposalIcon: React.FC> = (props) => { + return ( + + + + + ) +} + +const FIXED_MIN_DOLLAR_FOR_ACHIEVEMENT = BIG_TEN + +interface Props { + ifo: Ifo + publicIfoData: PublicIfoData +} + +const Container = styled(Flex)` + justify-content: space-between; + flex-direction: column; + align-items: center; + text-align: left; + gap: 16px; + + ${({ theme }) => theme.mediaQueries.md} { + flex-direction: row; + align-items: initial; + } +` + +const AchievementFlex = styled(Flex)<{ isFinished: boolean }>` + ${({ isFinished }) => (isFinished ? 'filter: grayscale(100%)' : '')}; + text-align: left; +` + +const InlinePrize = styled(Flex)` + display: inline-flex; + vertical-align: top; +` + +const DescriptionText = styled(Text)` + a { + font-weight: 600; + color: ${({ theme }) => theme.colors.primary}; + } +` + +const textFormatter = (text: string) => { + if (!text) { + return '' + } + return text + .replace(/\[(.*)\]\((.*)\)/, `$1`) + .replace(/\\n/, '\n') +} + +const IfoAchievement: React.FC> = ({ ifo, publicIfoData }) => { + const { t } = useTranslation() + const tokenName = ifo.token.symbol?.toLowerCase() + const projectUrl = ifo.token.projectLink + const campaignTitle = ifo.name + const minLpForAchievement = publicIfoData.thresholdPoints + ? formatBigInt(publicIfoData.thresholdPoints, 3) + : FIXED_MIN_DOLLAR_FOR_ACHIEVEMENT.div(publicIfoData.currencyPriceInUSD).toNumber().toFixed(3) + + return ( + + {ifo.chainId === ChainId.BSC ? ( + + + + + {`${t('Achievement')}:`} + + + + {t('IFO Shopper: %title%', { title: campaignTitle })} + + + + {publicIfoData.numberPoints} + + + + + {publicIfoData.currencyPriceInUSD.gt(0) ? ( + + {t('Commit ~%amount% %symbol% in total to earn!', { + amount: minLpForAchievement, + symbol: ifo.currency === bscTokens.cake ? 'CAKE' : 'LP', + })} + + ) : ( + + )} + + + + + + + + + + + {ifo.twitterUrl && ( + + + + )} + {ifo.telegramUrl && ( + + + + )} + + + + ) : ( + + + {`${t('IFO')}`} + + {campaignTitle} + + + + + + + + + + + {ifo.twitterUrl && ( + + + + )} + {ifo.telegramUrl && ( + + + + )} + + + )} + {ifo.description && ( + + + + )} + + ) +} + +export default IfoAchievement diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ActivateProfileButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ActivateProfileButton.tsx new file mode 100644 index 0000000000000..94b1302d27ce9 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ActivateProfileButton.tsx @@ -0,0 +1,81 @@ +import { isNativeIfoSupported, PROFILE_SUPPORTED_CHAIN_IDS } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { Button, Flex, ProfileAvatar, Text, useModalV2 } from '@pancakeswap/uikit' +import { NextLinkFromReactRouter } from '@pancakeswap/widgets-internal' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useRouter } from 'next/router' +import { useCallback, useMemo } from 'react' + +import { isTestnetChainId } from '@pancakeswap/chains' +import { useChainNames } from '../../../hooks/useChainNames' +import { ContentText, LinkTitle, WarningTips } from '../../WarningTips' +import { NetworkSwitcherModal } from './NetworkSwitcherModal' + +type Props = { + saleFinished?: boolean +} + +export function ActivateProfileButton({ saleFinished }: Props) { + const router = useRouter() + const { chainId } = useActiveChainId() + const profileSupported = useMemo(() => isNativeIfoSupported(chainId), [chainId]) + const { t } = useTranslation() + const { onOpen, onDismiss, isOpen } = useModalV2() + + const supportedChainIds = useMemo( + () => PROFILE_SUPPORTED_CHAIN_IDS.filter((profileChainId) => !isTestnetChainId(profileChainId)), + [], + ) + + const chainNames = useChainNames(supportedChainIds) + const to = useMemo(() => '/create-profile', []) + + // FIXME: not sure why push got canceled after network switching. Need further investigation + // It's a temp fix + const goToProfilePage = useCallback(() => window.setTimeout(() => router.push(to), 0), [router, to]) + + const tips = ( + + + {t('Pancakeswap profile is needed for IFO public sale eligibility.')} + + ) + + const button = profileSupported ? ( + + ) : ( + <> + + + + ) + + return ( + {t('How to Take Part')} »} + content={ + + {saleFinished + ? t('Activate PancakeSwap Profile to take part in next IFO.') + : t('You need to create a profile to participate in the IFO.')} + + } + /> + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeButton.tsx new file mode 100644 index 0000000000000..68f9e6b6d8979 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeButton.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from '@pancakeswap/localization' +import { SpaceProps } from 'styled-system' +import { ChainId, CurrencyAmount, Currency } from '@pancakeswap/sdk' +import { Button, useModalV2, Loading } from '@pancakeswap/uikit' +import { useCallback, useEffect, useState } from 'react' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useSwitchNetwork } from 'hooks/useSwitchNetwork' + +// import { useChainNames } from '../../../hooks/useChainNames' +import { useIfoSourceChain } from '../../../hooks/useIfoSourceChain' +import { BRIDGE_STATE, useBridgeICake } from '../../../hooks/useBridgeICake' +import { BridgeICakeModal } from './BridgeICakeModal' + +type Props = { + ifoId: string + + ifoChainId: ChainId + // The amount of icake on source chain + icake?: CurrencyAmount + // The amount of icake on destination chain + dstIcake?: CurrencyAmount + + buttonVisible?: boolean +} & SpaceProps + +export function BridgeButton({ ifoChainId, icake, dstIcake, buttonVisible = true, ifoId, ...props }: Props) { + const { chainId } = useActiveChainId() + const sourceChain = useIfoSourceChain(ifoChainId) + const isCurrentChainSourceChain = chainId === sourceChain + const [isSwitching, setIsSwitching] = useState(false) + const { switchNetworkAsync } = useSwitchNetwork() + const switchToSourceChain = useCallback( + () => sourceChain && !isCurrentChainSourceChain && switchNetworkAsync(sourceChain), + [sourceChain, switchNetworkAsync, isCurrentChainSourceChain], + ) + // const nativeIfoSupported = useMemo(() => isNativeIfoSupported(chainId), [chainId]) + const { t } = useTranslation() + const { onOpen, onDismiss, isOpen } = useModalV2() + const { state, bridge, isBridging, isBridged, clearBridgeHistory } = useBridgeICake({ + ifoId, + icake, + dstIcake, + srcChainId: sourceChain, + ifoChainId, + onUserReject: onDismiss, + }) + // const sourceChainName = useChainNames(PROFILE_SUPPORTED_CHAIN_IDS) + // const ifoChainName = useChainNames([ifoChainId]) + + const onBridgeClick = useCallback(async () => { + if (isCurrentChainSourceChain) { + bridge() + return + } + try { + setIsSwitching(true) + await switchToSourceChain() + } catch (e) { + console.error(e) + } finally { + setIsSwitching(false) + } + }, [isCurrentChainSourceChain, switchToSourceChain, bridge]) + + const onModalDismiss = useCallback(() => { + if (isBridged) { + clearBridgeHistory() + } + return onDismiss() + }, [onDismiss, isBridged, clearBridgeHistory]) + + useEffect(() => { + if (state.state !== BRIDGE_STATE.INITIAL) { + onOpen() + } + }, [state.state, onOpen]) + + const loading = isSwitching || isBridging + + return ( + <> + + {buttonVisible && ( + + )} + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeICakeModal.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeICakeModal.tsx new file mode 100644 index 0000000000000..aa0c0c9687103 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/BridgeICakeModal.tsx @@ -0,0 +1,264 @@ +import { + Button, + Modal, + ModalV2, + ModalBody, + ModalV2Props, + Text, + Flex, + LinkExternal, + Card, + CardBody, + Spinner, + CheckmarkCircleIcon, + LogoRoundIcon, + ArrowForwardIcon, +} from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import styled from 'styled-components' +import { SpaceProps } from 'styled-system' +import { ChainId, Currency, CurrencyAmount } from '@pancakeswap/sdk' +import { formatAmount } from '@pancakeswap/utils/formatFractions' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { useChainName } from '../../../hooks/useChainNames' +import { BridgeState, BRIDGE_STATE, useBridgeMessageUrl, useBridgeSuccessTxUrl } from '../../../hooks/useBridgeICake' +import { ICakeLogo, IfoIcon } from '../../Icons' + +type Props = { + // iCAKE on source chain to bridge + icake?: CurrencyAmount + + sourceChainId?: ChainId + ifoChainId?: ChainId + + state: BridgeState +} & ModalV2Props + +const StyledModal = styled(Modal)` + ${({ theme }) => theme.mediaQueries.md} { + width: 514px; + } +` + +const BodyTextMain = styled(Text).attrs({ + fontSize: '0.875rem', +})`` + +const BodyText = styled(Text).attrs({ + fontSize: '0.875rem', + color: 'textSubtle', +})`` + +const MessageLink = styled(LinkExternal).attrs({ + external: true, + bold: true, +})`` + +const StyledCardBody = styled(CardBody)` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding: 0.625rem 1rem; + background-color: ${({ theme }) => theme.colors.background}; +` + +const ICakeDisplayContainer = styled(Flex).attrs({ + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'flex-start', +})`` + +export function BridgeICakeModal({ icake, sourceChainId, ifoChainId, state, ...rest }: Props) { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const isCurrentChainSourceChain = chainId === sourceChainId + const sourceChainName = useChainName(sourceChainId) + + const renderModal = () => { + switch (state.state) { + case BRIDGE_STATE.INITIAL: + return ( + + + + {t( + 'To participate in the cross chain Public Sale, you need to bridge your iCAKE to the blockchain where the IFO will be hosted on.', + )} + + + {t( + 'Before or during the sale, you may bridge you iCAKE again if you’ve added more CAKE or extended your lock staking position.', + )} + + + + + + + + {t('Your iCAKE on %chainName%', { + chainName: sourceChainName, + })} + + + {formatAmount(icake)} + + + + + + + + + ) + case BRIDGE_STATE.PENDING_WALLET_SIGN: + case BRIDGE_STATE.PENDING_SOURCE_CHAIN_TX: + case BRIDGE_STATE.PENDING_CROSS_CHAIN_TX: + case BRIDGE_STATE.FINISHED: + return + default: + return null + } + } + + return {renderModal()} +} + +const StyledStateModal = styled(Modal)` + ${({ theme }) => theme.mediaQueries.md} { + width: 343px; + } +` + +const StateTitle = styled(Text).attrs({ + bold: true, + fontSize: '1rem', +})`` + +type BridgeStateModalProps = { + sourceChainId?: ChainId + ifoChainId?: ChainId + icake?: CurrencyAmount + state: BridgeState +} + +export function BridgeStateModal({ state, icake, sourceChainId, ifoChainId }: BridgeStateModalProps) { + const { t } = useTranslation() + const sourceChainName = useChainName(sourceChainId) + const ifoChainName = useChainName(ifoChainId) + const messageUrl = useBridgeMessageUrl(state) + const txUrl = useBridgeSuccessTxUrl(state) + + const isSuccess = state.state === BRIDGE_STATE.FINISHED + const crossChainInfo = !isSuccess ? ( + + {t('From %sourceChainName% to %ifoChainName%', { + sourceChainName, + ifoChainName, + })} + + ) : null + + const link = + messageUrl && !isSuccess ? ( + + {t('Track in LayerZero Explorer')} + + ) : null + + const txLink = txUrl ? ( + + {t('View on %ifoChainName% %tx%', { + ifoChainName, + tx: state.state === BRIDGE_STATE.FINISHED ? `${state.dstTxHash?.slice(0, 8)}...` || '' : '', + })} + + ) : null + + if (!icake || !sourceChainId || !ifoChainId) { + return null + } + + const renderTips = () => { + switch (state.state) { + case BRIDGE_STATE.PENDING_WALLET_SIGN: + return {t('Proceed in your wallet')} + case BRIDGE_STATE.PENDING_SOURCE_CHAIN_TX: + return {t('Waiting for transaction to be confirmed')} + case BRIDGE_STATE.PENDING_CROSS_CHAIN_TX: + return ( + <> + {t('Est. time: 2-5 minutes')} + {t('Waiting for bridge to confirm')} + + ) + default: + return null + } + } + + const statusIcon = isSuccess ? ( + + ) : ( + + ) + + return ( + + + + {statusIcon} + {!isSuccess && ( + + {t('Bridging %amount% iCAKE', { + amount: formatAmount(icake) || '', + })} + + )} + {crossChainInfo} + + + {renderTips()} + + {link} + {isSuccess && {t('Transaction receipt')}:} + {txLink} + + + + ) +} + +type BridgeStateIconDisplayProps = { + srcChainId?: ChainId + ifoChainId?: ChainId + state: BridgeState +} & SpaceProps + +export function BridgeStateIconDisplay({ state, srcChainId, ifoChainId, ...props }: BridgeStateIconDisplayProps) { + const content = + state.state === BRIDGE_STATE.FINISHED ? ( + <> + + + + ) : ( + <> + + + + + + ) + + return ( + + {content} + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ClaimButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ClaimButton.tsx new file mode 100644 index 0000000000000..b5fcf59d4b9f7 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ClaimButton.tsx @@ -0,0 +1,69 @@ +import { PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { AutoRenewIcon, Button, useToast } from '@pancakeswap/uikit' +import { useWeb3React } from '@pancakeswap/wagmi' +import { ToastDescriptionWithTx } from 'components/Toast' +import useCatchTxError from 'hooks/useCatchTxError' +import { WalletIfoData } from 'views/Ifos/types' + +interface Props { + poolId: PoolIds + ifoVersion: number + walletIfoData: WalletIfoData +} + +const ClaimButton: React.FC> = ({ poolId, ifoVersion, walletIfoData }) => { + const userPoolCharacteristics = walletIfoData[poolId] + const { t } = useTranslation() + const { toastSuccess } = useToast() + const { account, chain } = useWeb3React() + const { fetchWithCatchTxError } = useCatchTxError() + + const setPendingTx = (isPending: boolean) => walletIfoData.setPendingTx(isPending, poolId) + + const handleClaim = async () => { + const receipt = await fetchWithCatchTxError(() => { + setPendingTx(true) + if (!walletIfoData?.contract || !account) { + throw new Error('Invalid wallet ifo data contract or account') + } + if (ifoVersion === 1 && walletIfoData.version === 1) { + return walletIfoData.contract.write.harvest({ account, chain }) + } + if (walletIfoData.version === 3) { + return walletIfoData.contract.write.harvestPool([poolId === PoolIds.poolBasic ? 0 : 1], { account, chain }) + } + if (walletIfoData.version === 7) { + return walletIfoData.contract.write.harvestPool([poolId === PoolIds.poolBasic ? 0 : 1], { account, chain }) + } + if (walletIfoData.version === 8) { + return walletIfoData.contract.write.harvestPool([poolId === PoolIds.poolBasic ? 0 : 1], { account, chain }) + } + throw new Error('Invalid wallet ifo data version') + }) + if (receipt?.status) { + walletIfoData.setIsClaimed(poolId) + toastSuccess( + t('Success!'), + + {t('You have successfully claimed available tokens.')} + , + ) + } + setPendingTx(false) + } + + return ( + + ) +} + +export default ClaimButton diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeButton.tsx new file mode 100644 index 0000000000000..817a6ffb950ef --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeButton.tsx @@ -0,0 +1,115 @@ +import { Ifo, isCrossChainIfoSupportedOnly, PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { Button, IfoGetTokenModal, useModal, useToast } from '@pancakeswap/uikit' +import { getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { getTokenListTokenUrl, getTokenLogoURLByAddress } from '@pancakeswap/widgets-internal' +import BigNumber from 'bignumber.js' +import { ToastDescriptionWithTx } from 'components/Toast' +import { useTokenBalanceByChain } from 'hooks/useTokenBalance' +import { useCallback, useMemo } from 'react' +import { useCurrentBlock } from 'state/block/hooks' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' + +import { useUserVeCakeStatus } from 'components/CrossChainVeCakeModal/hooks/useUserVeCakeStatus' +import { logGTMIfoCommitEvent } from 'utils/customGTMEventTracking' +import ContributeModal from './ContributeModal' + +interface Props { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +} +const ContributeButton: React.FC> = ({ poolId, ifo, publicIfoData, walletIfoData }) => { + const publicPoolCharacteristics = publicIfoData[poolId] + const userPoolCharacteristics = walletIfoData[poolId] + + const isPendingTx = userPoolCharacteristics?.isPendingTx + const amountTokenCommittedInLP = userPoolCharacteristics?.amountTokenCommittedInLP + const limitPerUserInLP = publicPoolCharacteristics?.limitPerUserInLP + + const { t } = useTranslation() + const { toastSuccess } = useToast() + const currentBlock = useCurrentBlock() + const { balance: userCurrencyBalance } = useTokenBalanceByChain(ifo.currency.address, ifo.chainId) + const currencyImageUrl = useMemo( + () => getTokenListTokenUrl(ifo.currency) || getTokenLogoURLByAddress(ifo.currency.address, ifo.currency.chainId), + [ifo.currency], + ) + const { isProfileSynced } = useUserVeCakeStatus(ifo.chainId) + const isCrossChainIfo = useMemo(() => isCrossChainIfoSupportedOnly(ifo.chainId), [ifo.chainId]) + + // Refetch all the data, and display a message when fetching is done + const handleContributeSuccess = async (amount: BigNumber, txHash: string) => { + await Promise.all([publicIfoData.fetchIfoData(currentBlock), walletIfoData.fetchIfoData()]) + toastSuccess( + t('Success!'), + + {t('You have contributed %amount% CAKE to this IFO!', { + amount: getBalanceNumber(amount), + })} + , + ) + } + + const [onPresentContributeModal] = useModal( + , + false, + ) + + const [onPresentGetTokenModal] = useModal( + , + false, + ) + + const presentContributeModal = useCallback(() => { + onPresentContributeModal() + logGTMIfoCommitEvent(poolId) + }, [onPresentContributeModal, poolId]) + + const noNeedCredit = useMemo(() => ifo.version >= 3.1 && poolId === PoolIds.poolBasic, [ifo.version, poolId]) + + const isMaxCommitted = useMemo( + () => + (!noNeedCredit && + walletIfoData.ifoCredit?.creditLeft && + walletIfoData.ifoCredit?.creditLeft.isLessThanOrEqualTo(0)) || + (limitPerUserInLP?.isGreaterThan(0) && amountTokenCommittedInLP?.isGreaterThanOrEqualTo(limitPerUserInLP)), + [amountTokenCommittedInLP, limitPerUserInLP, noNeedCredit, walletIfoData.ifoCredit?.creditLeft], + ) + + // In a Cross-Chain Public Sale (poolUnlimited), + // the user needs to have credit (iCAKE) available to participate and an active profile + const isCrossChainAndNoProfileOrCredit = useMemo( + () => + poolId === PoolIds.poolUnlimited && + isCrossChainIfo && + (walletIfoData.ifoCredit?.credit.eq(0) || !isProfileSynced), + [isCrossChainIfo, isProfileSynced, poolId, walletIfoData.ifoCredit?.credit], + ) + + const isDisabled = useMemo( + () => isPendingTx || isMaxCommitted || publicIfoData.status !== 'live' || isCrossChainAndNoProfileOrCredit, + [isPendingTx, isMaxCommitted, publicIfoData.status, isCrossChainAndNoProfileOrCredit], + ) + + return ( + + ) +} + +export default ContributeButton diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeModal.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeModal.tsx new file mode 100644 index 0000000000000..e00dec9e92f6c --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ContributeModal.tsx @@ -0,0 +1,262 @@ +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { MaxUint256 } from '@pancakeswap/swap-sdk-core' +import { CAKE } from '@pancakeswap/tokens' +import { + BalanceInput, + Box, + Button, + Flex, + IfoHasVestingNotice, + Image, + Link, + Modal, + ModalBody, + Text, + TooltipText, + useToast, + useTooltip, +} from '@pancakeswap/uikit' +import { formatNumber, getBalanceAmount } from '@pancakeswap/utils/formatBalance' +import { getFullDecimalMultiplier } from '@pancakeswap/utils/getFullDecimalMultiplier' +import BigNumber from 'bignumber.js' +import ApproveConfirmButtons from 'components/ApproveConfirmButtons' +import { ToastDescriptionWithTx } from 'components/Toast' +import useApproveConfirmTransaction from 'hooks/useApproveConfirmTransaction' +import { useCallWithGasPrice } from 'hooks/useCallWithGasPrice' +import { useMemo, useState } from 'react' +import { logGTMIfoCommitTxnSentEvent } from 'utils/customGTMEventTracking' +import { parseUnits } from 'viem' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' + +interface Props { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData + userCurrencyBalance: BigNumber + creditLeft: BigNumber + onSuccess: (amount: BigNumber, txHash: string) => void + onDismiss?: () => void +} + +const multiplierValues = [0.1, 0.25, 0.5, 0.75, 1] + +const ContributeModal: React.FC> = ({ + poolId, + ifo, + publicIfoData, + walletIfoData, + userCurrencyBalance, + creditLeft, + onDismiss, + onSuccess, +}) => { + const publicPoolCharacteristics = publicIfoData[poolId] + const userPoolCharacteristics = walletIfoData[poolId] + + const { currency, articleUrl } = ifo + const { toastSuccess } = useToast() + const limitPerUserInLP = publicPoolCharacteristics?.limitPerUserInLP + const vestingInformation = publicPoolCharacteristics?.vestingInformation + const amountTokenCommittedInLP = userPoolCharacteristics?.amountTokenCommittedInLP + const { contract } = walletIfoData + const [value, setValue] = useState('') + const { callWithGasPrice } = useCallWithGasPrice() + const { t } = useTranslation() + const multiplier = useMemo(() => getFullDecimalMultiplier(currency.decimals), [currency]) + const valueWithTokenDecimals = useMemo(() => new BigNumber(value).times(multiplier), [value, multiplier]) + + const cake = CAKE[ifo.chainId] + const label = cake ? t('Max. CAKE entry') : t('Max. token entry') + + const { isApproving, isApproved, isConfirmed, isConfirming, handleApprove, handleConfirm } = + useApproveConfirmTransaction({ + token: currency, + spender: contract?.address, + minAmount: value ? parseUnits(value as `${number}`, currency.decimals) : undefined, + onApproveSuccess: ({ receipt }) => { + toastSuccess( + t('Successfully Enabled!'), + + {t('You can now participate in the %symbol% IFO.', { symbol: ifo.token.symbol })} + , + ) + }, + onConfirm: () => { + return callWithGasPrice(contract as any, 'depositPool', [ + valueWithTokenDecimals.integerValue(), + poolId === PoolIds.poolBasic ? 0 : 1, + ]) + }, + onSuccess: async ({ receipt }) => { + logGTMIfoCommitTxnSentEvent(poolId) + await onSuccess(valueWithTokenDecimals, receipt.transactionHash) + onDismiss?.() + }, + }) + + // in v3 max token entry is based on ifo credit and hard cap limit per user minus amount already committed + const maximumTokenEntry = useMemo(() => { + if (!creditLeft || (ifo.version >= 3.1 && poolId === PoolIds.poolBasic)) { + // limit of 0 in Basic Sale means Unlimited + if (limitPerUserInLP?.isEqualTo(0)) return BigNumber(MaxUint256.toString()) + + return limitPerUserInLP?.minus(amountTokenCommittedInLP || new BigNumber(0)) + } + if (limitPerUserInLP?.isGreaterThan(0)) { + return limitPerUserInLP.minus(amountTokenCommittedInLP || new BigNumber(0)).isLessThanOrEqualTo(creditLeft) + ? limitPerUserInLP.minus(amountTokenCommittedInLP || new BigNumber(0)) + : creditLeft + } + return creditLeft + }, [creditLeft, limitPerUserInLP, amountTokenCommittedInLP, ifo.version, poolId]) + + // include user balance for input + const maximumTokenCommittable = useMemo(() => { + return maximumTokenEntry?.isLessThanOrEqualTo(userCurrencyBalance) ? maximumTokenEntry : userCurrencyBalance + }, [maximumTokenEntry, userCurrencyBalance]) + + const basicTooltipContent = + ifo.version >= 3.1 + ? t( + 'For the basic sale, Max CAKE entry is capped by minimum between your average CAKE balance in the iCAKE, or the pool’s hard cap. To increase the max entry, Stake more CAKE into the iCAKE', + ) + : t( + 'For the private sale, each eligible participant will be able to commit any amount of CAKE up to the maximum commit limit, which is published along with the IFO voting proposal.', + ) + + const unlimitedToolipContent = ( + + {t('For the public sale, Max CAKE entry is capped by')} + + {t('the number of iCAKE.')}{' '} + + + {t('Lock more CAKE for longer durations to increase the maximum number of CAKE you can commit to the sale.')} + + + ) + + const { targetRef, tooltip, tooltipVisible } = useTooltip( + poolId === PoolIds.poolBasic ? basicTooltipContent : unlimitedToolipContent, + {}, + ) + + const isWarning = + valueWithTokenDecimals.isGreaterThan(userCurrencyBalance) || + valueWithTokenDecimals.isGreaterThan(maximumTokenEntry || new BigNumber(0)) + + return ( + + + + + {tooltipVisible && tooltip} + {label}: + + {limitPerUserInLP?.isEqualTo(0) && poolId === PoolIds.poolBasic + ? t('No limit') + : `${formatNumber( + getBalanceAmount(maximumTokenEntry || new BigNumber(0), currency.decimals).toNumber(), + 3, + 3, + )} ${ifo.currency.symbol}`} + + + + {t('Commit')}: + + + {currency.symbol} + + + { + if (isWarning) { + // auto adjust to max value + setValue(getBalanceAmount(maximumTokenCommittable).toString()) + } + }} + mb="8px" + /> + {isWarning && ( + + {valueWithTokenDecimals.isGreaterThan(userCurrencyBalance) + ? t('Insufficient Balance') + : t('Exceeded max CAKE entry')} + + )} + + {t('Balance: %balance%', { + balance: getBalanceAmount(userCurrencyBalance, currency.decimals).toString(), + })} + + + {multiplierValues.map((multiplierValue, index) => { + const multiplierResultValue = getBalanceAmount(maximumTokenCommittable.times(multiplierValue)).toString() + return ( + + ) + })} + + {vestingInformation?.percentage && vestingInformation.percentage > 0 ? ( + + ) : null} + + {t( + 'If you don’t commit enough CAKE, you may not receive a meaningful amount of IFO tokens, or you may not receive any IFO tokens at all.', + )} + + {t('Read more')} + + + + + + + ) +} + +export default ContributeModal diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/CrossChainVeCakeTips.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/CrossChainVeCakeTips.tsx new file mode 100644 index 0000000000000..94e746a54f330 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/CrossChainVeCakeTips.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from '@pancakeswap/localization' +import { ChainId } from '@pancakeswap/sdk' + +import { useUserVeCakeStatus } from 'components/CrossChainVeCakeModal/hooks/useUserVeCakeStatus' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useVeCakeBalance } from 'hooks/useTokenBalance' +import { useMemo } from 'react' +import { useIfoSourceChain } from 'views/Ifos/hooks/useIfoSourceChain' +import { useChainNames } from '../../../hooks/useChainNames' +import { ContentText, LinkTitle, WarningTips } from '../../WarningTips' +import { StakeButton } from './StakeButton' +import { SyncVeCakeButton } from './SyncVeCakeButton' + +type Props = { + ifoChainId: ChainId +} + +export function CrossChainVeCakeTips({ ifoChainId }: Props) { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const sourceChain = useIfoSourceChain(ifoChainId) + + const { balance: veCakeOnBSC } = useVeCakeBalance(ChainId.BSC) + + const { isSynced } = useUserVeCakeStatus(ifoChainId) + + const isCurrentChainSourceChain = useMemo(() => chainId === sourceChain, [chainId, sourceChain]) + + const noVeCAKE = useMemo(() => veCakeOnBSC.isZero(), [veCakeOnBSC]) + + const chainName = useChainNames([ifoChainId]) + + if (isSynced) { + return null + } + + const tips = noVeCAKE + ? t('You don’t have any veCAKE available for IFO public sale.') + : !isSynced + ? isCurrentChainSourceChain + ? t('You must sync your veCAKE again (for an updated iCAKE) if you have updated your veCAKE staking.') + : t( + 'Switch chain to BNB and sync your veCAKE again (for an updated iCAKE) if you have updated your veCAKE staking.', + ) + : t('Sync your veCAKE to participate in this sale on %chain%', { + chain: chainName, + }) + + const action = noVeCAKE ? : + + return ( + {t('How to Take Part')} »} + content={{tips}} + /> + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ICakeTips.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ICakeTips.tsx new file mode 100644 index 0000000000000..30561551a57df --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/ICakeTips.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from '@pancakeswap/localization' +import { ChainId } from '@pancakeswap/sdk' +import { Address } from 'viem' + +import { StakeButton } from './StakeButton' +import { useICakeBridgeStatus } from '../../../hooks/useIfoCredit' +import { useChainNames } from '../../../hooks/useChainNames' +import { BridgeButton } from './BridgeButton' +import { WarningTips, LinkTitle, ContentText } from '../../WarningTips' + +type Props = { + ifoId: string + + ifoChainId: ChainId + + ifoAddress?: Address +} + +export function ICakeTips({ ifoChainId, ifoId, ifoAddress }: Props) { + const { t } = useTranslation() + const { noICake, hasBridged, shouldBridgeAgain, sourceChainCredit, destChainCredit } = useICakeBridgeStatus({ + ifoChainId, + ifoAddress, + }) + const chainName = useChainNames([ifoChainId]) + + if (hasBridged) { + return ( + + ) + } + + const tips = noICake + ? t('You don’t have any iCAKE available for IFO public sale.') + : shouldBridgeAgain + ? t('Bridge iCAKE again if you have extended your CAKE staking or added more CAKE') + : t('Bridge your iCAKE to participate this sale on %chain%', { + chain: chainName, + }) + + const action = noICake ? ( + + ) : ( + + ) + + return ( + {t('How to Take Part')} »} + content={{tips}} + /> + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IFORequirements.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IFORequirements.tsx new file mode 100644 index 0000000000000..1fd8af70c72b8 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IFORequirements.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import { Text, Flex, AccountIcon, TeamBattleIcon, Box, useTooltip, LinkExternal } from '@pancakeswap/uikit' +import { useAccount } from 'wagmi' +import { useTranslation } from '@pancakeswap/localization' + +import OkNFTIcon from './Icons/OkNFT' +import OkProfilePointsIcon from './Icons/OkProfilePoints' +import TransWithElement from '../../TransWithElement' + +const NotOkNFT = ({ admissionProfile }) => { + const { t } = useTranslation() + + const keyword = '%Pancake Squad NFT%' + + const rawText = t(`Set %Pancake Squad NFT% as Pancake Profile avatar`) + + return ( + + + {t('Pancake Squad NFT')} + +
+ + } + /> + ) +} + +const NotOkProfilePoints = ({ pointThreshold }) => { + const { address: account } = useAccount() + const { t } = useTranslation() + + const keyword = '%Pancake Profile%' + + const rawText = t(`Reach %point% or more %Pancake Profile% points`, { point: pointThreshold }) + + return ( + +
+ + {t('Pancake Profile')} + + + } + /> + ) +} + +const configCriterias = (pointThreshold: number, admissionProfile: string, t) => ({ + isQualifiedNFT: { + OkIcon: OkNFTIcon, + okMsg: t('Eligible NFT avatar found!'), + notOkMsg: , + NotOkIcon: AccountIcon, + name: t('Pancake Squad'), + }, + isQualifiedPoints: { + OkIcon: OkProfilePointsIcon, + okMsg: t('Profile Points threshold met!'), + notOkMsg: , + NotOkIcon: TeamBattleIcon, + name: t('Profile points'), + }, +}) + +function Item({ type, isOk, isSingle, pointThreshold, admissionProfile }) { + const { t } = useTranslation() + + const config = useMemo( + () => configCriterias(pointThreshold, admissionProfile, t), + [t, pointThreshold, admissionProfile], + ) + + const name = config[type]?.name + const Icon = isOk ? config[type]?.OkIcon : config[type]?.NotOkIcon + const msg = isOk ? config[type]?.okMsg : config[type]?.notOkMsg + + const { tooltipVisible, targetRef, tooltip } = useTooltip(msg, { placement: 'bottom' }) + + return ( + + + + + + {name} + + {tooltipVisible && tooltip} + + ) +} + +export default function IFORequirements({ criterias, pointThreshold, admissionProfile }) { + if (!criterias?.length) return null + + const isSingle = criterias.length === 1 + + return ( + + {criterias.map(({ type, value }) => { + return ( + + ) + })} + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkNFT.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkNFT.tsx new file mode 100644 index 0000000000000..ead21f20e109e --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkNFT.tsx @@ -0,0 +1,25 @@ +import { Svg, SvgProps } from '@pancakeswap/uikit' + +const OkNFTIcon: React.FC> = (props) => ( + + + + + + +) + +export default OkNFTIcon diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkProfilePoints.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkProfilePoints.tsx new file mode 100644 index 0000000000000..ba8bb79f6d85a --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/Icons/OkProfilePoints.tsx @@ -0,0 +1,21 @@ +import { Svg, SvgProps } from '@pancakeswap/uikit' + +const ProfilePoints: React.FC> = (props) => ( + + + + + +) + +export default ProfilePoints diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardActions.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardActions.tsx new file mode 100644 index 0000000000000..e8997771206b8 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardActions.tsx @@ -0,0 +1,92 @@ +import { IfoSkeletonCardActions } from '@pancakeswap/uikit' + +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import ConnectWalletButton from 'components/ConnectWalletButton' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { isBasicSale } from 'views/Ifos/hooks/v7/helpers' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import { useAccount } from 'wagmi' + +import { useMemo } from 'react' +import { EnableStatus } from '../types' +import { ActivateProfileButton } from './ActivateProfileButton' +import ClaimButton from './ClaimButton' +import ContributeButton from './ContributeButton' +import { SwitchNetworkTips } from './SwitchNetworkTips' + +interface Props { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData + hasProfile: boolean + isLoading: boolean + isEligible: boolean + enableStatus: EnableStatus +} + +const IfoCardActions: React.FC> = ({ + poolId, + ifo, + publicIfoData, + walletIfoData, + hasProfile, + isLoading, + isEligible, + enableStatus, +}) => { + const { address: account } = useAccount() + const { chainId } = useActiveChainId() + const userPoolCharacteristics = walletIfoData[poolId] + + const needClaim = useMemo( + () => + publicIfoData.status === 'finished' && + !userPoolCharacteristics?.hasClaimed && + (userPoolCharacteristics?.offeringAmountInToken.isGreaterThan(0) || + userPoolCharacteristics?.refundingAmountInLP.isGreaterThan(0)), + [ + publicIfoData.status, + userPoolCharacteristics?.hasClaimed, + userPoolCharacteristics?.offeringAmountInToken, + userPoolCharacteristics?.refundingAmountInLP, + ], + ) + + if (isLoading) { + return + } + + if (!account) { + return + } + + if (!hasProfile && !isBasicSale(publicIfoData[poolId]?.saleType)) { + return + } + + if (ifo.version >= 7 && ifo.chainId !== chainId) { + return + } + + if (needClaim) { + return + } + + if ( + (enableStatus !== EnableStatus.ENABLED && publicIfoData.status === 'coming_soon') || + (ifo.version >= 3.1 && poolId === PoolIds.poolBasic && !isEligible) + ) { + return null + } + + return ( + <> + {(publicIfoData.status === 'live' || publicIfoData.status === 'coming_soon') && ( + + )} + + ) +} + +export default IfoCardActions diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardDetails.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardDetails.tsx new file mode 100644 index 0000000000000..d51e1b0eac374 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardDetails.tsx @@ -0,0 +1,319 @@ +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { CAKE } from '@pancakeswap/tokens' +import { Box, Flex, IfoSkeletonCardDetails, Skeleton, Text, TooltipText, useTooltip } from '@pancakeswap/uikit' +import { BIG_ONE_HUNDRED } from '@pancakeswap/utils/bigNumber' +import { formatNumber, getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { DAY_IN_SECONDS } from '@pancakeswap/utils/getTimePeriods' +import BigNumber from 'bignumber.js' +import { useStablecoinPrice } from 'hooks/useStablecoinPrice' +import { ReactNode, useMemo } from 'react' +import { styled } from 'styled-components' +import { multiplyPriceByAmount } from 'utils/prices' +import { isBasicSale } from 'views/Ifos/hooks/v7/helpers' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' + +const ZERO = new BigNumber(0) +const ONE = new BigNumber(1) + +export interface IfoCardDetailsProps { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData + isEligible: boolean +} + +export interface FooterEntryProps { + label: ReactNode + value: ReactNode + tooltipContent?: string +} + +const StyledIfoCardDetails = styled(Flex)` + padding: 16px; + margin: 0 -12px -12px; + background-color: ${({ theme }) => theme.colors.background}; +` + +const FooterEntry: React.FC> = ({ label, value, tooltipContent }) => { + const { targetRef, tooltip, tooltipVisible } = useTooltip(tooltipContent, { placement: 'bottom-start' }) + + return ( + + {tooltipVisible && tooltip} + {tooltipContent ? ( + + + {label} + + + ) : ( + + {label} + + )} + {value ? ( + + {value} + + ) : ( + + )} + + ) +} + +const MaxTokenEntry = ({ + maxToken, + ifo, + poolId, + basicSale, +}: { + maxToken: number + ifo: Ifo + poolId: PoolIds + basicSale?: boolean +}) => { + const cake = CAKE[ifo.chainId] + const isCurrencyCake = cake && ifo.currency.wrapped?.equals(cake) + const isV3 = ifo.version >= 3 + const { t } = useTranslation() + + const basicTooltipContent = + ifo.version >= 3.1 && !basicSale + ? t( + 'For the private sale, each eligible participant will be able to commit any amount of CAKE up to the maximum commit limit, which is published along with the IFO voting proposal.', + ) + : t( + 'For the basic sale, Max CAKE entry is capped by minimum between your average CAKE balance in the iCAKE, or the pool’s hard cap. To increase the max entry, Stake more CAKE into the iCAKE', + ) + + const unlimitedToolipContent = + ifo.version >= 3.1 ? ( + + {t('For the public sale, Max CAKE entry is capped by')} + + {t('the number of iCAKE.')} + + + {t('Lock more CAKE for longer durations to increase the maximum number of CAKE you can commit to the sale.')} + + + ) : ( + t( + 'For the unlimited sale, Max CAKE entry is capped by your average CAKE balance in the iCake. To increase the max entry, Stake more CAKE into the iCake', + ) + ) + + const tooltipContent = poolId === PoolIds.poolBasic ? basicTooltipContent : unlimitedToolipContent + + const { targetRef, tooltip, tooltipVisible } = useTooltip(tooltipContent, { placement: 'bottom-start' }) + const label = isCurrencyCake ? t('Max. CAKE entry') : t('Max. token entry') + const price = useStablecoinPrice(ifo.currency) + + const dollarValueOfToken = multiplyPriceByAmount(price, maxToken, ifo.currency.decimals) + + return ( + <> + {isV3 && tooltipVisible && tooltip} + + {label} + + ) : ( + label + ) + } + value={ + 0 ? 'text' : 'failure'}> + {`${formatNumber(maxToken, 3, 3)} ${!isCurrencyCake ? ifo.currency.symbol : ''} ${` ~($${formatNumber( + dollarValueOfToken, + 0, + 0, + )})`}`} + + } + /> + + ) +} + +const IfoCardDetails: React.FC> = ({ + isEligible, + poolId, + ifo, + publicIfoData, + walletIfoData, +}) => { + const { t } = useTranslation() + const { status, currencyPriceInUSD } = publicIfoData + const poolCharacteristic = publicIfoData[poolId] + const walletCharacteristic = walletIfoData[poolId] + const hasTax = poolCharacteristic?.hasTax + + let version3MaxTokens = walletIfoData.ifoCredit?.creditLeft + ? // if creditLeft > limit show limit else show creditLeft + walletIfoData.ifoCredit.creditLeft.gt( + poolCharacteristic?.limitPerUserInLP.minus(walletCharacteristic?.amountTokenCommittedInLP || ZERO) || ZERO, + ) + ? poolCharacteristic?.limitPerUserInLP.minus(walletCharacteristic?.amountTokenCommittedInLP || ZERO) + : walletIfoData.ifoCredit.creditLeft + : null + + // unlimited pool just show the credit left + version3MaxTokens = poolId === PoolIds.poolUnlimited ? walletIfoData.ifoCredit?.creditLeft : version3MaxTokens + + /* Format start */ + const maxLpTokens = + (ifo.version === 3 || (ifo.version >= 3.1 && poolId === PoolIds.poolUnlimited)) && ifo.isActive + ? version3MaxTokens + ? getBalanceNumber(version3MaxTokens, ifo.currency.decimals) + : 0 + : getBalanceNumber(poolCharacteristic?.limitPerUserInLP || ZERO, ifo.currency.decimals) + const taxRate = `${poolCharacteristic?.taxRate || 0}%` + + const totalCommittedPercent = poolCharacteristic?.totalAmountPool + .div(poolCharacteristic?.raisingAmountPool) + .times(100) + .toFixed(2) + const totalLPCommitted = getBalanceNumber(poolCharacteristic?.totalAmountPool || ZERO, ifo.currency.decimals) + const totalLPCommittedInUSD = currencyPriceInUSD.times(totalLPCommitted) + const totalCommitted = `~$${formatNumber(totalLPCommittedInUSD.toNumber(), 0, 0)} (${totalCommittedPercent}%)` + + const sumTaxesOverflow = poolCharacteristic?.totalAmountPool + .minus(poolCharacteristic.raisingAmountPool) + .times(poolCharacteristic.taxRate) + .times(0.01) + const sumTaxesOverflowInUSD = currencyPriceInUSD.times(sumTaxesOverflow || ZERO) + const pricePerTokenWithFeeToOriginalRatio = sumTaxesOverflow + ?.plus(poolCharacteristic?.raisingAmountPool || ZERO) + .div(poolCharacteristic?.offeringAmountPool || ONE) + .div(poolCharacteristic?.raisingAmountPool.div(poolCharacteristic.offeringAmountPool) || ONE) + const pricePerTokenWithFeeNumber = pricePerTokenWithFeeToOriginalRatio + ?.times(ifo.tokenOfferingPrice || ONE) + .toNumber() + const maxPrecision = ifo.tokenOfferingPrice && ifo.tokenOfferingPrice < 1 ? 4 : 2 + + const pricePerTokenWithFee = `~$${formatNumber(pricePerTokenWithFeeNumber || 0, 0, maxPrecision)}` + const raisingTokenToBurn = + ifo[poolId]?.cakeToBurn || + (sumTaxesOverflowInUSD.gt(0) && + `${formatNumber(getBalanceNumber(sumTaxesOverflow || ZERO), 0, 2)} (~$${formatNumber( + getBalanceNumber(sumTaxesOverflowInUSD), + 0, + 2, + )})`) + + const maxToken = ifo.version >= 3.1 && poolId === PoolIds.poolBasic && !isEligible ? 0 : maxLpTokens + const basicSale = useMemo(() => isBasicSale(poolCharacteristic?.saleType), [poolCharacteristic?.saleType]) + + const tokenEntry = useMemo( + () => + // For Basic Pool, if max lp tokens is 0, it means Unlimited so don't show the max token entry + !(basicSale && maxToken === 0) && ( + + ), + [poolId, ifo, maxToken, basicSale], + ) + + const durationInSeconds = ifo.version >= 3.2 ? poolCharacteristic?.vestingInformation?.duration ?? 0 : 0 + const vestingDays = Math.ceil(durationInSeconds / DAY_IN_SECONDS) + + /* Format end */ + const renderBasedOnIfoStatus = () => { + if (status === 'coming_soon') { + return ( + <> + {tokenEntry} + + {raisingTokenToBurn ? : null} + + + ) + } + if (status === 'live') { + return ( + <> + {tokenEntry} + {poolId === PoolIds.poolBasic && ( + + )} + + {hasTax && ( + + )} + + + {raisingTokenToBurn ? : null} + {ifo.version >= 3.2 && + poolCharacteristic?.vestingInformation?.percentage && + poolCharacteristic.vestingInformation.percentage > 0 ? ( + <> + + + + ) : null} + + ) + } + + if (status === 'finished') { + return ( + + {poolId === PoolIds.poolBasic && tokenEntry} + {hasTax && } + + + {raisingTokenToBurn ? : null} + {ifo.version > 1 ? ( + + ) : null} + {ifo.version > 1 && hasTax ? ( + + ) : null} + + ) + } + return + } + + return {renderBasedOnIfoStatus()} +} + +export default IfoCardDetails diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardTokens.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardTokens.tsx new file mode 100644 index 0000000000000..1abaeb7e3dd7c --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoCardTokens.tsx @@ -0,0 +1,401 @@ +import { Ifo, PoolIds, cakeBnbLpToken } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { Token } from '@pancakeswap/sdk' +import { bscTokens } from '@pancakeswap/tokens' +import { + AutoRenewIcon, + BalanceWithLoading, + Box, + BunnyPlaceholderIcon, + Button, + CheckmarkCircleIcon, + ErrorIcon, + Flex, + FlexProps, + HelpIcon, + IfoPercentageOfTotal, + IfoSkeletonCardTokens, + IfoVestingAvailableToClaim, + Message, + MessageText, + Text, + useTooltip, +} from '@pancakeswap/uikit' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { getBalanceAmount, getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { NumberDisplay, type NumberDisplayProps } from '@pancakeswap/widgets-internal' +import { TokenImage, TokenPairImage } from 'components/TokenImage' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { ReactNode, useMemo } from 'react' +import { isBasicSale } from 'views/Ifos/hooks/v7/helpers' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import { useAccount } from 'wagmi' + +import { TextLink } from '../../IfoCardStyles' +import StakeVaultButton from '../StakeVaultButton' +import { EnableStatus } from '../types' +import { CrossChainVeCakeTips } from './CrossChainVeCakeTips' +import IFORequirements from './IFORequirements' + +interface TokenSectionProps extends FlexProps { + primaryToken?: Token + secondaryToken?: Token +} + +const TokenSection: React.FC> = ({ + primaryToken, + secondaryToken, + children, + ...props +}) => { + const renderTokenComponent = () => { + if (!primaryToken) { + return + } + + if (primaryToken && secondaryToken) { + return ( + + ) + } + + return + } + + return ( + + {renderTokenComponent()} +
{children}
+
+ ) +} + +const CommitTokenSection: React.FC> = ({ + commitToken, + ...props +}) => { + if (commitToken.equals(cakeBnbLpToken)) { + return + } + return +} + +const Label = (props) => + +const Value = (props: NumberDisplayProps) => ( + +) + +interface IfoCardTokensProps { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData + hasProfile: boolean + isLoading: boolean + onApprove: () => Promise + enableStatus: EnableStatus + criterias?: any + isEligible?: boolean +} + +const OnSaleInfo = ({ token, saleAmount, distributionRatio }) => { + const { t } = useTranslation() + return ( + + + + + + {t('%ratio%% of total sale', { ratio: distributionRatio })} + + + + ) +} + +const IfoCardTokens: React.FC> = ({ + criterias, + isEligible, + poolId, + ifo, + publicIfoData, + walletIfoData, + hasProfile, + isLoading, + onApprove, + enableStatus, +}) => { + const { address: account } = useAccount() + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const { targetRef, tooltip, tooltipVisible } = useTooltip( + t( + 'Sorry, you didn’t contribute enough CAKE to meet the minimum threshold. You didn’t buy anything in this sale, but you can still reclaim your CAKE.', + ), + { placement: 'bottom' }, + ) + + const publicPoolCharacteristics = publicIfoData[poolId] + const isPublicPoolBasicSale = isBasicSale(publicPoolCharacteristics?.saleType) + const userPoolCharacteristics = walletIfoData[poolId] + const offeringAmountInToken = userPoolCharacteristics?.offeringAmountInToken + const amountTokenCommittedInLP = userPoolCharacteristics?.amountTokenCommittedInLP + const refundingAmountInLP = userPoolCharacteristics?.refundingAmountInLP + const spentAmount = amountTokenCommittedInLP?.minus(refundingAmountInLP || BIG_ZERO) + + const { currency, token, version } = ifo + const hasClaimed = userPoolCharacteristics?.hasClaimed + const distributionRatio = + (ifo.version >= 3 ? publicIfoData[poolId]?.distributionRatio ?? 0 : ifo[poolId]?.distributionRatio ?? 0) * 100 + + const tooltipContentOfSpent = t( + 'Based on "overflow" sales method. %refundingAmount% unspent %spentToken% are available to claim after the sale is completed.', + { + refundingAmount: getBalanceNumber(refundingAmountInLP || BIG_ZERO, ifo.currency.decimals).toFixed(4), + spentToken: ifo.currency.symbol, + }, + ) + const { + targetRef: tagTargetRefOfSpent, + tooltip: tagTooltipOfSpent, + tooltipVisible: tagTooltipVisibleOfSpent, + } = useTooltip(tooltipContentOfSpent, { + placement: 'bottom', + }) + + const hasNFT = useMemo(() => { + const data = criterias.find((obj) => obj.type === 'isQualifiedNFT') + const userHasNFT = data?.value + return userHasNFT + }, [criterias]) + + const renderTokenSection = () => { + if (isLoading) { + return + } + if (!account) { + return ( + = 3 ? publicIfoData[poolId]?.offeringAmountPool : ifo[poolId]?.saleAmount} + /> + ) + } + + let message: ReactNode | undefined + + const ifov31Msg = + ifo.version >= 3.1 && poolId === PoolIds.poolBasic && criterias?.length > 0 ? ( + + {!isEligible && ( + }> + + {t('Meet any one of the following requirements to be eligible.')} + + + )} + + {isEligible && ( + + + {hasNFT + ? t('Using eligible NFT for entry. Do not remove or edit your profile avatar before claiming.') + : t('You are eligible to participate in this Private Sale!')} + + + )} + + ) : null + + if ( + (ifo.version === 3 || (ifo.version >= 3.1 && poolId === PoolIds.poolUnlimited)) && + publicIfoData.status !== 'finished' && + hasProfile + ) { + // If Cross-Chain IFO + // if (ifo.chainId !== ChainId.BSC) { + message = + // } + // Phase this out later, as it applies at the same time + // else message = + } + + if (account && !hasProfile && !isPublicPoolBasicSale && publicIfoData.status !== 'finished') { + return ( + <> + = 3 ? publicIfoData[poolId]?.offeringAmountPool : ifo[poolId]?.saleAmount} + /> + {message} + + ) + } + + message = ifov31Msg || message + + if (publicIfoData.status === 'coming_soon') { + const offeringAmountPool = publicIfoData[poolId]?.offeringAmountPool + return ( + <> + + + {offeringAmountPool ? ( + + ) : null} + + + {t('%ratio%% of total sale', { ratio: distributionRatio })} + + {message} + {enableStatus !== EnableStatus.ENABLED && account && chainId === ifo.chainId && ( + + )} + + ) + } + if (publicIfoData.status === 'live') { + return ( + <> + + + {amountTokenCommittedInLP ? ( + + ) : null} + + + + + + {tagTooltipVisibleOfSpent && tagTooltipOfSpent} + + + + + + + + + + + {offeringAmountInToken ? : null} + {version >= 3.2 && + publicPoolCharacteristics?.vestingInformation?.percentage && + publicPoolCharacteristics.vestingInformation.percentage > 0 ? ( + + ) : null} + + {message} + + ) + } + + if (publicIfoData.status === 'finished') { + return amountTokenCommittedInLP?.isEqualTo(0) ? ( + + + {t('You didn’t participate in this sale!')} + {!isPublicPoolBasicSale && + (ifov31Msg || ( + <> + + {t('To participate in the next IFO, lock some CAKE in the fixed-term staking CAKE pool!')} + + + {t('How does it work?')} » + + + + ))} + + ) : ( + <> + + + + {refundingAmountInLP ? : null} + {hasClaimed && } + + + + + + + {offeringAmountInToken ? : null} + {!hasClaimed && offeringAmountInToken?.isEqualTo(0) && ( +
+ +
+ )} + {hasClaimed && } +
+
+ {hasClaimed && ( + + {t('You’ve successfully claimed tokens back.')} + + )} + + ) + } + return null + } + return ( + + {tooltipVisible && tooltip} + {renderTokenSection()} + + ) +} + +export default IfoCardTokens diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/ReleasedTokenInfo.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/ReleasedTokenInfo.tsx new file mode 100644 index 0000000000000..7eac8ca01d374 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/ReleasedTokenInfo.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import { styled } from 'styled-components' +import { Flex, Box, Text, ReleasedChart } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import BigNumber from 'bignumber.js' +import { Ifo } from '@pancakeswap/ifos' +import { getBalanceNumber, formatNumber } from '@pancakeswap/utils/formatBalance' +import isUndefinedOrNull from '@pancakeswap/utils/isUndefinedOrNull' + +const Dot = styled.div<{ isActive?: boolean }>` + width: 8px; + height: 8px; + border-radius: 50%; + align-self: center; + background-color: ${({ theme, isActive }) => (isActive ? theme.colors.secondary : '#d7caec')}; +` + +interface ReleasedTokenInfoProps { + ifo: Ifo + amountReleased: BigNumber + amountInVesting: BigNumber + isVestingOver: boolean +} + +const ReleasedTokenInfo: React.FC> = ({ + ifo, + amountReleased, + amountInVesting, + isVestingOver, +}) => { + const { t } = useTranslation() + const { token } = ifo + + const amount = useMemo(() => { + const totalReleasedAmount = isVestingOver ? amountReleased.plus(amountInVesting) : amountReleased + const released = getBalanceNumber(totalReleasedAmount, token.decimals) + const inVesting = getBalanceNumber(amountInVesting, token.decimals) + const total = new BigNumber(released).plus(inVesting) + const releasedPercentage = new BigNumber(released).div(total).times(100).toFixed(2) + const releasedPercentageDisplay = isUndefinedOrNull(releasedPercentage) ? '0' : releasedPercentage + const inVestingPercentage = new BigNumber(inVesting).div(total).times(100).toFixed(2) + const inVestingPercentageDisplay = isUndefinedOrNull(inVestingPercentage) ? '0' : inVestingPercentage + + return { + released, + releasedPercentage, + releasedPercentageDisplay, + inVesting, + inVestingPercentage, + inVestingPercentageDisplay, + } + }, [token, amountReleased, amountInVesting, isVestingOver]) + + return ( + + + + + + + + {t('Released')} + + + + + {`${formatNumber(amount.released, 4, 4)}`} + + + {`(${amount.releasedPercentageDisplay}%)`} + + + + + + + + {t('Vested')} + + + + + {isVestingOver ? '-' : `${formatNumber(amount.inVesting, 4, 4)}`} + + + {`(${amount.inVestingPercentageDisplay}%)`} + + + + + + ) +} + +export default ReleasedTokenInfo diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalAvailableClaim.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalAvailableClaim.tsx new file mode 100644 index 0000000000000..c92d0b5b630dd --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalAvailableClaim.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { Flex, Box, Text } from '@pancakeswap/uikit' +import { NumberDisplay } from '@pancakeswap/widgets-internal' +import { TokenImage } from 'components/TokenImage' +import { LightGreyCard } from 'components/Card' +import { useTranslation } from '@pancakeswap/localization' +import { Ifo } from '@pancakeswap/ifos' +import BigNumber from 'bignumber.js' + +interface TotalAvailableClaimProps { + ifo: Ifo + amountAvailableToClaim: BigNumber +} + +const TotalAvailableClaim: React.FC> = ({ + ifo, + amountAvailableToClaim, +}) => { + const { t } = useTranslation() + const { token } = ifo + + const amountAvailable = useMemo( + () => (amountAvailableToClaim.gt(0) ? amountAvailableToClaim.div(10 ** token.decimals) : '0'), + [token, amountAvailableToClaim], + ) + + return ( + + + + + + {t('%symbol% available to claim', { symbol: token.symbol })} + + + + + + ) +} + +export default TotalAvailableClaim diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalPurchased.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalPurchased.tsx new file mode 100644 index 0000000000000..156ab2313b4f2 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/TotalPurchased.tsx @@ -0,0 +1,93 @@ +import { Flex, Box, Text, BalanceWithLoading, HelpIcon, useTooltip } from '@pancakeswap/uikit' +import { LightGreyCard } from 'components/Card' +import { TokenImage } from 'components/TokenImage' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { WalletIfoData } from 'views/Ifos/types' +import { getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { useTranslation } from '@pancakeswap/localization' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' + +interface TotalPurchasedProps { + ifo: Ifo + poolId: PoolIds + walletIfoData: WalletIfoData +} + +const TotalPurchased: React.FC> = ({ ifo, poolId, walletIfoData }) => { + const { t } = useTranslation() + const { token } = ifo + const offeringAmountInToken = walletIfoData[poolId]?.offeringAmountInToken + const amountTokenCommittedInLP = walletIfoData[poolId]?.amountTokenCommittedInLP + const refundingAmountInLP = walletIfoData[poolId]?.refundingAmountInLP + const spentAmount = amountTokenCommittedInLP?.minus(refundingAmountInLP || BIG_ZERO) + + const tooltipContentOfSpent = t( + 'Based on "overflow" sales method. %refundingAmount% unspent %spentToken% are available to claim after the sale is completed.', + { + refundingAmount: getBalanceNumber(refundingAmountInLP, ifo.currency.decimals).toFixed(4), + spentToken: ifo.currency.symbol, + }, + ) + const { + targetRef: tagTargetRefOfSpent, + tooltip: tagTooltipOfSpent, + tooltipVisible: tagTooltipVisibleOfSpent, + } = useTooltip(tooltipContentOfSpent, { + placement: 'bottom', + }) + + return ( + + + + + + {t('Total %symbol% purchased', { symbol: token.symbol })} + + + + + + + + + {t('Your %symbol% committed', { symbol: ifo.currency.symbol })} + + + + + + + + + {t('Your %symbol% spent', { symbol: ifo.currency.symbol })} + + {tagTooltipVisibleOfSpent && tagTooltipOfSpent} + + + + + + + + + ) +} + +export default TotalPurchased diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/index.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/index.tsx new file mode 100644 index 0000000000000..5bc4eba5cecad --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/IfoVestingCard/index.tsx @@ -0,0 +1,108 @@ +import { useCallback, useMemo } from 'react' +import BigNumber from 'bignumber.js' +import { Flex, Box, Text, IfoProgressStepper, IfoVestingFooter } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import Divider from 'components/Divider' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import useIfoVesting from 'views/Ifos/hooks/useIfoVesting' +import { getFullDisplayBalance } from '@pancakeswap/utils/formatBalance' +import { BIG_ONE, BIG_ZERO } from '@pancakeswap/utils/bigNumber' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import TotalPurchased from './TotalPurchased' +import TotalAvailableClaim from './TotalAvailableClaim' +import ReleasedTokenInfo from './ReleasedTokenInfo' +import ClaimButton from '../ClaimButton' +import VestingClaimButton from '../VestingClaimButton' +import { SwitchNetworkTips } from '../SwitchNetworkTips' + +interface IfoVestingCardProps { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +} + +const IfoVestingCard: React.FC> = ({ + poolId, + ifo, + publicIfoData, + walletIfoData, +}) => { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const { token } = ifo + const { vestingStartTime } = publicIfoData + const userPool = walletIfoData[poolId] + const vestingInformation = publicIfoData[poolId]?.vestingInformation + + const { amountReleased, amountInVesting, amountAvailableToClaim, amountAlreadyClaimed, isVestingOver } = + useIfoVesting({ + poolId, + publicIfoData, + walletIfoData, + }) + + const amountClaimed = useMemo( + () => (amountAlreadyClaimed.gt(0) ? getFullDisplayBalance(amountAlreadyClaimed, token.decimals, 4) : '0'), + [token, amountAlreadyClaimed], + ) + + const getNow = useCallback(() => { + return Date.now() + }, []) + + const releaseRate = useMemo(() => { + const rate = new BigNumber(userPool?.vestingAmountTotal || BIG_ZERO).div(vestingInformation?.duration || BIG_ONE) + const rateBalance = getFullDisplayBalance(rate, token.decimals, 5) + return new BigNumber(rateBalance).gte(0.00001) ? rateBalance : '< 0.00001' + }, [vestingInformation, userPool, token]) + + const claimButton = !userPool?.isVestingInitialized ? ( + + ) : ( + + ) + + const claimAction = ifo.chainId === chainId ? claimButton : + + return ( + + + + + + + + + {t('You’ve already claimed %amount% %symbol%', { symbol: token.symbol, amount: amountClaimed })} + + {claimAction} + + + + ) +} + +export default IfoVestingCard diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/NetworkSwitcherModal.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/NetworkSwitcherModal.tsx new file mode 100644 index 0000000000000..1fa7b0af009e9 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/NetworkSwitcherModal.tsx @@ -0,0 +1,94 @@ +import { Button, Modal, ModalV2, ModalBody, ModalV2Props, Text, Flex, useTooltip, Box } from '@pancakeswap/uikit' +import Image from 'next/image' +import { useTranslation } from '@pancakeswap/localization' +import styled from 'styled-components' +import { ReactNode, useCallback } from 'react' +import { ChainId } from '@pancakeswap/sdk' + +import { useSwitchNetwork } from 'hooks/useSwitchNetwork' + +import { useChainNames } from '../../../hooks/useChainNames' + +type Props = { + supportedChains?: readonly ChainId[] + title?: ReactNode + description?: ReactNode + tips?: ReactNode + buttonText?: ReactNode + onSwitchNetworkSuccess?: () => void +} & ModalV2Props + +const StyledModal = styled(Modal)` + ${({ theme }) => theme.mediaQueries.md} { + width: 336px; + } +` +const BodyTitle = styled(Text).attrs({ + bold: true, +})`` + +const BodyText = styled(Text).attrs({ + fontSize: '0.875rem', +})`` + +export function NetworkSwitcherModal({ + title, + buttonText, + description, + tips, + supportedChains, + onSwitchNetworkSuccess, + ...rest +}: Props) { + const { t } = useTranslation() + const chainNames = useChainNames(supportedChains) + const { switchNetworkAsync } = useSwitchNetwork() + const onSwitch = useCallback(async () => { + if (!supportedChains?.length) { + return + } + try { + const result = await switchNetworkAsync(supportedChains[0]) + if (result) { + onSwitchNetworkSuccess?.() + } + } catch (e) { + console.error(e) + } + }, [switchNetworkAsync, supportedChains, onSwitchNetworkSuccess]) + const { targetRef, tooltip } = useTooltip({tips}, { + placement: 'left', + manualVisible: true, + tooltipOffset: [0, -22], + isInPortal: false, + }) + + return ( + + + + + {t("It's a %chain% only feature", { + chain: chainNames, + })} + + {description} + + instructor-bunny + {tips && tooltip} + + + + + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/StakeButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/StakeButton.tsx new file mode 100644 index 0000000000000..a553d35e1b2a3 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/StakeButton.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from '@pancakeswap/localization' +import { CAKE_VAULT_SUPPORTED_CHAINS, isCakeVaultSupported } from '@pancakeswap/pools' +import { Button, Flex, Text, useModalV2 } from '@pancakeswap/uikit' +import { useCallback, useMemo } from 'react' +import { SpaceProps } from 'styled-system' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { isTestnetChainId } from '@pancakeswap/chains' +import { useRouter } from 'next/router' +import { useChainNames } from '../../../hooks/useChainNames' +import { ICakeLogo } from '../../Icons' +import { NetworkSwitcherModal } from './NetworkSwitcherModal' + +interface StakeButtonProps extends SpaceProps {} + +export function StakeButton(props: StakeButtonProps) { + const { chainId } = useActiveChainId() + const router = useRouter() + const cakeVaultSupported = useMemo(() => isCakeVaultSupported(chainId), [chainId]) + const { t } = useTranslation() + const { onOpen, onDismiss, isOpen } = useModalV2() + + const supportedChainIds = useMemo( + () => CAKE_VAULT_SUPPORTED_CHAINS.filter((vaultChainId) => !isTestnetChainId(vaultChainId)), + [], + ) + const chainNames = useChainNames(supportedChainIds) + + const goToCakeStakingPage = useCallback(() => router.push('/cake-staking'), [router]) + + const tips = ( + + + {t('Stake CAKE to obtain iCAKE - in order to be eligible in this public sale.')} + + ) + + return !cakeVaultSupported ? ( + <> + + + + ) : null +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SwitchNetworkTips.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SwitchNetworkTips.tsx new file mode 100644 index 0000000000000..909637343f146 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SwitchNetworkTips.tsx @@ -0,0 +1,62 @@ +import { Box, Message, MessageText, Flex } from '@pancakeswap/uikit' +import { ChainLogo } from '@pancakeswap/widgets-internal' +import { useTranslation } from '@pancakeswap/localization' +import { ChainId } from '@pancakeswap/sdk' +import { useCallback, MouseEvent } from 'react' +import styled from 'styled-components' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useSwitchNetwork } from 'hooks/useSwitchNetwork' + +import { useChainNames } from '../../../hooks/useChainNames' +import { MessageTextLink } from '../../IfoCardStyles' + +const StyledMessage = styled(Message)` + padding: 1rem; + border-color: ${({ theme }) => theme.colors.cardBorder}; + background-color: ${({ theme }) => theme.colors.background}; +` + +type Props = { + ifoChainId: ChainId +} + +export function SwitchNetworkTips({ ifoChainId }: Props) { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const chainName = useChainNames([ifoChainId]) + const { switchNetworkAsync } = useSwitchNetwork() + + const onSwitchNetwork = useCallback( + (e: MouseEvent) => { + e.preventDefault() + if (chainId === ifoChainId) { + return + } + switchNetworkAsync(ifoChainId) + }, + [chainId, ifoChainId, switchNetworkAsync], + ) + + if (chainId === ifoChainId) { + return null + } + + return ( + }> + + + + {t('This IFO is hosted on %chain%.', { + chain: chainName, + })} + {' '} + + {t('Switch network')} + {' '} + {t('to participate')} + + + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SyncVeCakeButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SyncVeCakeButton.tsx new file mode 100644 index 0000000000000..d8637a8902d17 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/SyncVeCakeButton.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from '@pancakeswap/localization' +import { ChainId } from '@pancakeswap/sdk' +import { Button, Loading, useModalV2 } from '@pancakeswap/uikit' +import { useCallback, useMemo, useState } from 'react' +import { SpaceProps } from 'styled-system' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useSwitchNetwork } from 'hooks/useSwitchNetwork' + +// import { useChainNames } from '../../../hooks/useChainNames' +import { IfoChainId } from '@pancakeswap/widgets-internal/ifo/constants' +import { CrossChainVeCakeModal } from 'components/CrossChainVeCakeModal' +import { useIfoSourceChain } from '../../../hooks/useIfoSourceChain' + +type Props = { + ifoChainId: ChainId + + buttonVisible?: boolean +} & SpaceProps + +export function SyncVeCakeButton({ ifoChainId, buttonVisible = true, ...props }: Props) { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const sourceChain = useIfoSourceChain(ifoChainId) + const { onDismiss, isOpen, setIsOpen } = useModalV2() + const { switchNetworkAsync } = useSwitchNetwork() + + const [isSwitching, setIsSwitching] = useState(false) + + const isCurrentChainSourceChain = useMemo(() => chainId === sourceChain, [chainId, sourceChain]) + const switchToSourceChain = useCallback( + () => sourceChain && !isCurrentChainSourceChain && switchNetworkAsync(sourceChain), + [sourceChain, switchNetworkAsync, isCurrentChainSourceChain], + ) + + const onSyncClick = useCallback(async () => { + if (isCurrentChainSourceChain) { + setIsOpen(true) + return + } + + try { + setIsSwitching(true) + await switchToSourceChain() + } catch (e) { + console.error(e) + } finally { + setIsSwitching(false) + } + }, [isCurrentChainSourceChain, switchToSourceChain, setIsOpen]) + + return ( + <> + } + /> + + {buttonVisible && ( + + )} + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/VestingClaimButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/VestingClaimButton.tsx new file mode 100644 index 0000000000000..bfbe14d1343b7 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/VestingClaimButton.tsx @@ -0,0 +1,82 @@ +import { PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { AutoRenewIcon, Button, useToast } from '@pancakeswap/uikit' +import { useWeb3React } from '@pancakeswap/wagmi' +import BigNumber from 'bignumber.js' +import { ToastDescriptionWithTx } from 'components/Toast' +import useCatchTxError from 'hooks/useCatchTxError' +import { useCallback } from 'react' +import { Address } from 'viem' +import { WalletIfoData } from 'views/Ifos/types' + +interface Props { + poolId: PoolIds + amountAvailableToClaim: BigNumber + walletIfoData: WalletIfoData +} + +const ClaimButton: React.FC> = ({ poolId, amountAvailableToClaim, walletIfoData }) => { + const userPoolCharacteristics = walletIfoData[poolId] + const { t } = useTranslation() + const { toastSuccess } = useToast() + const { account, chain } = useWeb3React() + const { fetchWithCatchTxError } = useCatchTxError() + + const setPendingTx = useCallback( + (isPending: boolean) => { + return walletIfoData.setPendingTx(isPending, poolId) + }, + [poolId, walletIfoData], + ) + + const handleClaim = useCallback(async () => { + if (walletIfoData.version !== 3 && walletIfoData.version !== 7 && walletIfoData.version !== 8) { + throw new Error('Invalid IFO version') + } + const receipt = await fetchWithCatchTxError(() => { + setPendingTx(true) + if (!account || !walletIfoData.contract) { + throw new Error('Invalid wallet ifo contract or account') + } + return walletIfoData.version === 3 + ? walletIfoData.contract.write.release([userPoolCharacteristics?.vestingId as Address], { account, chain }) + : walletIfoData.version === 8 + ? walletIfoData.contract.write.release([userPoolCharacteristics?.vestingId as Address], { account, chain }) + : walletIfoData.contract.write.release([userPoolCharacteristics?.vestingId as Address], { account, chain }) + }) + if (receipt?.status) { + walletIfoData.setIsClaimed(poolId) + toastSuccess( + t('Success!'), + + {t('You have successfully claimed available tokens.')} + , + ) + } + setPendingTx(false) + }, [ + walletIfoData, + fetchWithCatchTxError, + setPendingTx, + userPoolCharacteristics?.vestingId, + account, + chain, + poolId, + toastSuccess, + t, + ]) + + return ( + + ) +} + +export default ClaimButton diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/index.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/index.tsx new file mode 100644 index 0000000000000..0d060d8bde5c6 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoPoolCard/index.tsx @@ -0,0 +1,256 @@ +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { ContextApi, useTranslation } from '@pancakeswap/localization' +import { + Box, + Card, + CardBody, + CardFooter, + CardHeader, + ExpandableLabel, + Flex, + HelpIcon, + Text, + useTooltip, +} from '@pancakeswap/uikit' +import { useMemo, useState } from 'react' +import { useProfile } from 'state/profile/hooks' +import { styled } from 'styled-components' +import useCriterias from 'views/Ifos/hooks/v3/useCriterias' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import { useAccount } from 'wagmi' +import { isBasicSale } from '../../../hooks/v7/helpers' +import { CardConfigReturn, EnableStatus } from '../types' +import IfoCardActions from './IfoCardActions' +import IfoCardDetails from './IfoCardDetails' +import IfoCardTokens from './IfoCardTokens' +import IfoVestingCard from './IfoVestingCard' + +const StyledCard = styled(Card)` + width: 100%; + margin: 0 auto; + padding: 0 0 3px 0; + height: fit-content; +` +const StyledCardFooter = styled(CardFooter)` + padding: 16px; + margin: 0 -12px -12px; + background: ${({ theme }) => theme.colors.background}; + text-align: center; +` + +interface IfoCardProps { + poolId: PoolIds + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData + onApprove: () => Promise + enableStatus: EnableStatus +} + +export const cardConfig = ( + t: ContextApi['t'], + poolId: PoolIds, + meta: { + version: number + needQualifiedPoints?: boolean + needQualifiedNFT?: boolean + saleType?: number + additionalClaimingFee?: boolean + }, +): CardConfigReturn => { + switch (poolId) { + case PoolIds.poolBasic: + // Sale type 2 is basic sale + if (meta?.version >= 3.1 && !isBasicSale(meta?.saleType)) { + const MSG_MAP = { + needQualifiedNFT: t('Set PancakeSquad NFT as Pancake Profile avatar.'), + needQualifiedPoints: t('Reach a certain Pancake Profile Points threshold.'), + } + + const msgs = Object.keys(meta) + .filter((criteria) => meta[criteria]) + .map((criteria) => MSG_MAP[criteria]) + .filter(Boolean) + + return { + title: t('Private Sale'), + variant: 'blue', + tooltip: msgs?.length ? ( + <> + + {msgs.length > 1 // one or multiple + ? t('Meet any one of the requirements to join:') + : t('Meet the following requirement to join:')} + + {msgs.map((msg) => ( + + {msg} + + ))} + + ) : ( + <> + ), + } + } + + return { + title: t('Basic Sale'), + variant: 'blue', + tooltip: t( + 'Every person can only commit a limited amount, but may expect a higher return per token committed.', + ), + } + case PoolIds.poolUnlimited: + return { + title: meta?.version >= 3.1 ? t('Public Sale') : t('Unlimited Sale'), + variant: 'violet', + tooltip: meta.additionalClaimingFee + ? t('No limits on the amount you can commit. Additional fee applies when claiming.') + : t('No limits on the amount you can commit.'), + } + + default: + return { title: '', variant: 'blue', tooltip: '' } + } +} + +const SmallCard: React.FC> = ({ + poolId, + ifo, + publicIfoData, + walletIfoData, + onApprove, + enableStatus, +}) => { + const { t } = useTranslation() + const { address: account } = useAccount() + + const admissionProfile = publicIfoData[poolId]?.admissionProfile + const pointThreshold = publicIfoData[poolId]?.pointThreshold + const vestingInformation = publicIfoData[poolId]?.vestingInformation + const saleType = publicIfoData[poolId]?.saleType + + const { needQualifiedNFT, needQualifiedPoints } = useMemo(() => { + return ifo.version >= 3.1 && poolId === PoolIds.poolBasic && !isBasicSale(saleType) + ? { + needQualifiedNFT: Boolean(admissionProfile), + needQualifiedPoints: pointThreshold ? pointThreshold > 0 : false, + } + : {} + }, [ifo.version, admissionProfile, pointThreshold, poolId, saleType]) + + const config = cardConfig(t, poolId, { + version: ifo.version, + needQualifiedNFT, + needQualifiedPoints, + saleType, + additionalClaimingFee: ifo[poolId]?.additionalClaimingFee, + }) + + const { hasActiveProfile, isLoading: isProfileLoading } = useProfile() + const { targetRef, tooltip, tooltipVisible } = useTooltip(config.tooltip, { placement: 'bottom' }) + + const isLoading = Boolean(isProfileLoading && needQualifiedNFT) || publicIfoData.status === 'idle' + + const { isEligible, criterias } = useCriterias(walletIfoData[poolId], { + needQualifiedNFT, + needQualifiedPoints, + }) + + const isVesting = useMemo(() => { + return ( + account && + ifo.version >= 3.2 && + vestingInformation?.percentage && + vestingInformation.percentage > 0 && + publicIfoData.status === 'finished' && + walletIfoData[poolId]?.amountTokenCommittedInLP.gt(0) + ) + }, [account, ifo, poolId, publicIfoData, vestingInformation, walletIfoData]) + + const cardTitle = ifo.cIFO ? `${config.title} (cIFO)` : config.title + + const [isExpanded, setIsExpanded] = useState(false) + + if (!isLoading && !publicIfoData[poolId]?.distributionRatio) { + return null + } + + return ( + <> + {tooltipVisible && tooltip} + + + + + {cardTitle} + +
+ +
+
+
+ + {isVesting ? ( + <> + + + setIsExpanded((prev) => !prev)}> + {isExpanded ? t('Hide') : t('Details')} + + {isExpanded && ( + + )} + + + ) : ( + <> + + + + + + + + + )} + +
+ + ) +} + +export default SmallCard diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/IfoRibbon.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoRibbon.tsx new file mode 100644 index 0000000000000..365a7a5214d91 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/IfoRibbon.tsx @@ -0,0 +1,173 @@ +import { Box, Flex, Heading, Progress, ProgressBar } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import { styled } from 'styled-components' +import { ChainId } from '@pancakeswap/sdk' +import { ReactNode, useMemo } from 'react' +import { useImageColor } from '@pancakeswap/hooks' + +import { PublicIfoData } from '../../types' +import LiveTimer, { SoonTimer } from './Timer' +import { IfoChainBoard } from '../IfoChainBoard' +import { getBannerUrl } from '../../helpers' + +const StyledProgress = styled(Progress)` + background-color: #281a5b; +` + +const Container = styled(Box)` + position: relative; +` + +const BigCurve = styled(Box)<{ $status?: PublicIfoData['status']; $dark?: boolean }>` + width: 150%; + position: absolute; + top: -150%; + bottom: 0; + left: 50%; + transform: translateX(-50%); + z-index: 1; + + ${({ theme }) => theme.mediaQueries.md} { + border-radius: 50%; + } + + ${({ $status, $dark, theme }) => { + switch ($status) { + case 'coming_soon': + return ` + background: ${$dark ? '#353547' : '#EFF3F4'}; + ` + case 'live': + return ` + background: linear-gradient(180deg, #8051D6 0%, #492286 100%); + ` + case 'finished': + return ` + background: ${theme.colors.input}; + ` + default: + return '' + } + }} +` + +const RibbonContainer = styled(Box)` + z-index: 2; + position: relative; +` + +const ChainBoardContainer = styled(Box)` + position: absolute; + top: -4rem; + left: 50%; + + ${({ theme }) => theme.mediaQueries.sm} { + left: unset; + top: unset; + right: 90px; + bottom: 3px; + } +` + +export const IfoRibbon = ({ + ifoId, + ifoChainId, + publicIfoData, +}: { + ifoChainId?: ChainId + publicIfoData: PublicIfoData + ifoId?: string +}) => { + const { status } = publicIfoData + const bannerUrl = useMemo(() => ifoId && getBannerUrl(ifoId), [ifoId]) + const { isDarkColor } = useImageColor({ url: bannerUrl }) + + let ribbon: ReactNode = null + switch (status) { + case 'finished': + ribbon = + break + case 'live': + ribbon = + break + case 'coming_soon': + ribbon = + break + default: + ribbon = null + } + + if (status === 'idle') { + return null + } + + return ( + + {status === 'live' && ( + + + + )} + + {ribbon} + + + + + + ) +} + +type RibbonProps = { + dark?: boolean +} + +const IfoRibbonEnd = () => { + const { t } = useTranslation() + return ( + <> + + + + {t('Sale Finished!')} + + + + ) +} + +const IfoRibbonSoon = ({ publicIfoData, dark }: { publicIfoData: PublicIfoData } & RibbonProps) => { + return ( + <> + + + + + + + + ) +} + +const IfoRibbonLive = ({ publicIfoData, dark }: { publicIfoData: PublicIfoData } & RibbonProps) => { + return ( + <> + + + + + + ) +} diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/StakeVaultButton.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/StakeVaultButton.tsx new file mode 100644 index 0000000000000..36fbfd847d54b --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/StakeVaultButton.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from '@pancakeswap/localization' +import { CAKE_VAULT_SUPPORTED_CHAINS, isCakeVaultSupported } from '@pancakeswap/pools' +import { Button, Flex, Text, useModalV2 } from '@pancakeswap/uikit' +import { useRouter } from 'next/router' +import { useCallback, useMemo } from 'react' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useConfig } from 'views/Ifos/contexts/IfoContext' + +import { isTestnetChainId } from '@pancakeswap/chains' +import { useChainNames } from '../../hooks/useChainNames' +import { ICakeLogo } from '../Icons' +import { NetworkSwitcherModal } from './IfoPoolCard/NetworkSwitcherModal' + +const StakeVaultButton = (props) => { + const { t } = useTranslation() + const { chainId } = useActiveChainId() + const router = useRouter() + const { isExpanded, setIsExpanded } = useConfig() as any + const isFinishedPage = router.pathname.includes('history') + const cakeVaultSupported = useMemo(() => isCakeVaultSupported(chainId), [chainId]) + + const supportedChainIds = useMemo( + () => CAKE_VAULT_SUPPORTED_CHAINS.filter((vaultChainId) => !isTestnetChainId(vaultChainId)), + [], + ) + const cakeVaultChainNames = useChainNames(supportedChainIds) + + const { onOpen, onDismiss, isOpen } = useModalV2() + + const scrollToTop = useCallback(() => { + window.scrollTo({ + top: 0, + behavior: 'auto', + }) + }, []) + + const handleClickButton = useCallback(() => { + if (!cakeVaultSupported) { + onOpen() + return + } + + // Always expand for mobile + if (!isExpanded) { + setIsExpanded(true) + } + + if (isFinishedPage) { + router.push('/ifo') + } else { + scrollToTop() + } + }, [cakeVaultSupported, onOpen, isExpanded, isFinishedPage, router, scrollToTop, setIsExpanded]) + + const tips = ( + + + {t('Stake CAKE to obtain iCAKE - in order to be eligible in the next IFO.')} + + ) + + return ( + <> + + + + ) +} + +export default StakeVaultButton diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/Timer.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/Timer.tsx new file mode 100644 index 0000000000000..b08be39555245 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/Timer.tsx @@ -0,0 +1,175 @@ +import { useTranslation } from '@pancakeswap/localization' +import { styled } from 'styled-components' +import { Flex, Heading, PocketWatchIcon, Text, Skeleton, Link, TimerIcon } from '@pancakeswap/uikit' +import getTimePeriods from '@pancakeswap/utils/getTimePeriods' +import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' +import { getBlockExploreLink } from 'utils' +import { PublicIfoData } from 'views/Ifos/types' +import { useActiveChainId } from 'hooks/useActiveChainId' + +interface Props { + publicIfoData: PublicIfoData + dark?: boolean +} + +const GradientText = styled(Heading)` + background: -webkit-linear-gradient(#ffd800, #eb8c00); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: 1px rgba(0, 0, 0, 0.5); +` + +const FlexGap = styled(Flex)<{ gap: string }>` + gap: ${({ gap }) => gap}; +` + +const USE_BLOCK_TIMESTAMP_UNTIL = 3 + +export const SoonTimer: React.FC> = ({ publicIfoData, dark }) => { + const { chainId } = useActiveChainId() + const { t } = useTranslation() + const { status, secondsUntilStart, startBlockNum } = publicIfoData + const currentBlockTimestamp = useCurrentBlockTimestamp() + const isLegacyBlockCountdown = typeof startBlockNum === 'number' + const now = isLegacyBlockCountdown ? currentBlockTimestamp : Math.floor(Date.now() / 1000) + const hoursLeft = publicIfoData.plannedStartTime && now ? (publicIfoData.plannedStartTime - Number(now)) / 3600 : 0 + const fallbackToBlockTimestamp = hoursLeft > USE_BLOCK_TIMESTAMP_UNTIL + let timeUntil: ReturnType | undefined + if (fallbackToBlockTimestamp) { + timeUntil = getTimePeriods((publicIfoData?.plannedStartTime || Number(now)) - Number(now)) + } else { + timeUntil = getTimePeriods(secondsUntilStart) + } + const textColor = dark ? '#EFF3F4' : '#674F9C' + + const countdownDisplay = + status !== 'idle' ? ( + <> + + + {t('Starts in')} + + + {timeUntil.days ? ( + <> + + {timeUntil.days} + + {t('d')} + + ) : null} + {timeUntil.days || timeUntil.hours ? ( + <> + + {timeUntil.hours} + + {t('h')} + + ) : null} + <> + + {!timeUntil.days && !timeUntil.hours && timeUntil.minutes === 0 ? '< 1' : timeUntil.minutes} + + {t('m')} + + + + + + ) : null + + const countdown = isLegacyBlockCountdown ? ( + + {countdownDisplay} + + ) : ( + countdownDisplay + ) + + return ( + + {status === 'idle' ? : countdown} + + ) +} + +const EndInHeading = styled(Heading)` + color: white; + font-size: 20px; + font-weight: 600; + line-height: 1.1; + + ${({ theme }) => theme.mediaQueries.md} { + font-size: 24px; + } +` + +const LiveNowHeading = styled(EndInHeading)` + color: white; + ${({ theme }) => theme.mediaQueries.md} { + background: -webkit-linear-gradient(#ffd800, #eb8c00); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: 1px rgba(0, 0, 0, 0.5); + } +` + +const LiveTimer: React.FC> = ({ publicIfoData }) => { + const { chainId } = useActiveChainId() + const { t } = useTranslation() + const { status, secondsUntilEnd, endBlockNum } = publicIfoData + const isLegacyBlockCountdown = typeof endBlockNum === 'number' + const timeUntil = getTimePeriods(secondsUntilEnd) + + const timeDisplay = + status !== 'idle' ? ( + <> + + + {`${t('Live Now')}!`} + + {t('Ends in')} + + + {timeUntil.days ? ( + <> + {timeUntil.days} + {t('d')} + + ) : null} + {timeUntil.days || timeUntil.hours ? ( + <> + {timeUntil.hours} + {t('h')} + + ) : null} + <> + + {!timeUntil.days && !timeUntil.hours && timeUntil.minutes === 0 ? '< 1' : timeUntil.minutes} + + {t('m')} + + + + + + ) : null + + const timeNode = isLegacyBlockCountdown ? ( + + {timeDisplay} + + ) : ( + timeDisplay + ) + + return ( + + {status === 'idle' ? : timeNode} + + ) +} + +export default LiveTimer diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/index.tsx b/apps/web/src/views/Idos/components/IfoFoldableCard/index.tsx new file mode 100644 index 0000000000000..b50513fe3cfe2 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/index.tsx @@ -0,0 +1,332 @@ +import { useIsWindowVisible } from '@pancakeswap/hooks' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { + Box, + Card, + CardBody, + CardFooter, + CardHeader, + ExpandableButton, + ExpandableLabel, + useMatchBreakpoints, + useToast, +} from '@pancakeswap/uikit' +import { useQuery } from '@tanstack/react-query' +import { ToastDescriptionWithTx } from 'components/Toast' +import { FAST_INTERVAL } from 'config/constants' +import useCatchTxError from 'hooks/useCatchTxError' +import { useERC20 } from 'hooks/useContract' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useCurrentBlock } from 'state/block/hooks' +import { styled } from 'styled-components' +import { requiresApproval } from 'utils/requiresApproval' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import { useAccount } from 'wagmi' +import { getBannerUrl } from '../../helpers' +import useIfoApprove from '../../hooks/useIfoApprove' +import { CardsWrapper } from '../IfoCardStyles' +import IfoAchievement from './Achievement' +import IfoPoolCard from './IfoPoolCard' +import { IfoRibbon } from './IfoRibbon' +import { EnableStatus } from './types' + +interface IfoFoldableCardProps { + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +} + +const StyledCard = styled(Card)<{ $isCurrent?: boolean }>` + width: 100%; + margin: auto; + border-top-left-radius: 32px; + border-top-right-radius: 32px; + + ${({ $isCurrent }) => + $isCurrent && + ` + border-top-left-radius: 0; + border-top-right-radius: 0; + > div { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + `} + + > div { + background: ${({ theme, $isCurrent }) => ($isCurrent ? theme.colors.gradientBubblegum : theme.colors.dropdown)}; + } + + ${({ theme }) => theme.mediaQueries.sm} { + border-top-left-radius: 32px; + border-top-right-radius: 32px; + + > div { + border-top-left-radius: 32px; + border-top-right-radius: 32px; + } + } +` + +const Header = styled(CardHeader)<{ ifoId: string; $isCurrent?: boolean }>` + display: flex; + justify-content: flex-end; + align-items: center; + height: ${({ $isCurrent }) => ($isCurrent ? '64px' : '112px')}; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + border-top-left-radius: 32px; + border-top-right-radius: 32px; + background-color: ${({ theme }) => theme.colors.dropdown}; + background-image: ${({ ifoId }) => `url('${getBannerUrl(ifoId)}')`}; + ${({ theme }) => theme.mediaQueries.md} { + height: 112px; + } +` + +export const StyledCardBody = styled(CardBody)` + padding: 24px 16px; + ${({ theme }) => theme.mediaQueries.md} { + padding: 24px; + } +` + +const StyledCardFooter = styled(CardFooter)` + padding: 0; + background: ${({ theme }) => theme.colors.backgroundAlt}; + text-align: center; +` + +// Active Ifo +export const IfoCurrentCard = ({ + ifo, + publicIfoData, + walletIfoData, +}: { + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +}) => { + const [isExpanded, setIsExpanded] = useState(false) + const { t } = useTranslation() + const { isMobile } = useMatchBreakpoints() + + return ( + <> + {isMobile && ( + +
+ + + )} + + + {!isMobile && ( + <> +
+ + + )} + + + setIsExpanded(!isExpanded)}> + {isExpanded ? t('Hide') : t('Details')} + + {isExpanded && } + + + + + ) +} + +const FoldableContent = styled.div<{ isVisible: boolean }>` + display: ${({ isVisible }) => (isVisible ? 'block' : 'none')}; +` + +// Past Ifo +const IfoFoldableCard = ({ + ifo, + publicIfoData, + walletIfoData, +}: { + ifo: Ifo + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +}) => { + const { asPath } = useRouter() + const [isExpanded, setIsExpanded] = useState(false) + const wrapperEl = useRef(null) + + useEffect(() => { + const hash = asPath.split('#')[1] + if (hash === ifo.id) { + setIsExpanded(true) + wrapperEl?.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [asPath, ifo]) + + return ( + + + +
+ setIsExpanded((prev) => !prev)} /> +
+ {isExpanded && ( + <> + + + )} +
+ + + + +
+
+ ) +} + +const IfoCard: React.FC> = ({ ifo, publicIfoData, walletIfoData }) => { + const currentBlock = useCurrentBlock() + const { fetchIfoData: fetchPublicIfoData, isInitialized: isPublicIfoDataInitialized, secondsUntilEnd } = publicIfoData + const { + contract, + fetchIfoData: fetchWalletIfoData, + resetIfoData: resetWalletIfoData, + isInitialized: isWalletDataInitialized, + } = walletIfoData + const [enableStatus, setEnableStatus] = useState(EnableStatus.DISABLED) + const { t } = useTranslation() + const { address: account } = useAccount() + const raisingTokenContract = useERC20(ifo.currency.address) + // Continue to fetch 2 more minutes / is vesting need get latest data + const isRecentlyActive = + (publicIfoData.status !== 'finished' || + (publicIfoData.status === 'finished' && secondsUntilEnd >= -120) || + (publicIfoData.status === 'finished' && + ifo.version >= 3.2 && + ((publicIfoData[PoolIds.poolBasic]?.vestingInformation?.percentage ?? 0) > 0 || + (publicIfoData[PoolIds.poolUnlimited]?.vestingInformation?.percentage ?? 0) > 0))) && + ifo.isActive + const onApprove = useIfoApprove(ifo, contract?.address) + const { toastSuccess } = useToast() + const { fetchWithCatchTxError } = useCatchTxError() + const isWindowVisible = useIsWindowVisible() + + const hasVesting = useMemo(() => { + return ( + account && + ifo.version >= 3.2 && + publicIfoData.status === 'finished' && + ((publicIfoData[PoolIds.poolBasic]?.vestingInformation?.percentage ?? 0) > 0 || + (publicIfoData[PoolIds.poolUnlimited]?.vestingInformation?.percentage ?? 0) > 0) && + (walletIfoData[PoolIds.poolBasic]?.amountTokenCommittedInLP.gt(0) || + walletIfoData[PoolIds.poolUnlimited].amountTokenCommittedInLP.gt(0)) + ) + }, [account, ifo, publicIfoData, walletIfoData]) + + useQuery({ + queryKey: ['fetchPublicIfoData', currentBlock, ifo.id], + queryFn: async () => fetchPublicIfoData(currentBlock), + enabled: Boolean(currentBlock && (isRecentlyActive || !isPublicIfoDataInitialized)), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }) + + useQuery({ + queryKey: ['fetchWalletIfoData', account, ifo.id], + queryFn: async () => fetchWalletIfoData(), + enabled: Boolean(isWindowVisible && (isRecentlyActive || !isWalletDataInitialized || hasVesting) && account), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + + ...((isRecentlyActive || hasVesting) && { + refetchInterval: FAST_INTERVAL, + }), + }) + + useEffect(() => { + if (!account && isWalletDataInitialized) { + resetWalletIfoData() + } + }, [account, isWalletDataInitialized, resetWalletIfoData]) + + const handleApprove = async () => { + const receipt = await fetchWithCatchTxError(() => { + setEnableStatus(EnableStatus.IS_ENABLING) + return onApprove() as any + }) + if (receipt?.status) { + toastSuccess( + t('Successfully Enabled!'), + + {t('You can now participate in the %symbol% IFO.', { symbol: ifo.token.symbol })} + , + ) + setEnableStatus(EnableStatus.ENABLED) + } else { + setEnableStatus(EnableStatus.DISABLED) + } + } + + useEffect(() => { + const checkAllowance = async () => { + const approvalRequired = await requiresApproval(raisingTokenContract, account || '0x', contract?.address || '0x') + setEnableStatus(approvalRequired ? EnableStatus.DISABLED : EnableStatus.ENABLED) + } + + if (account) { + checkAllowance() + } + }, [account, raisingTokenContract, contract, setEnableStatus]) + + const hasPoolBasic = Boolean(publicIfoData.poolBasic?.distributionRatio) + const hasPoolUnlimited = Boolean(publicIfoData.poolUnlimited?.distributionRatio) + const isSingleCard = publicIfoData.isInitialized && (!hasPoolBasic || !hasPoolUnlimited) + + return ( + <> + + = 3.1 && publicIfoData.poolBasic?.saleType !== 2} + $singleCard={isSingleCard} + > + {publicIfoData.poolBasic && walletIfoData.poolBasic && ( + + )} + + + + + ) +} + +export default IfoFoldableCard diff --git a/apps/web/src/views/Idos/components/IfoFoldableCard/types.ts b/apps/web/src/views/Idos/components/IfoFoldableCard/types.ts new file mode 100644 index 0000000000000..7ea717c59cfb1 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoFoldableCard/types.ts @@ -0,0 +1,13 @@ +import { ReactElement } from 'react' + +export enum EnableStatus { + ENABLED = 'enabled', + DISABLED = 'disabled', + IS_ENABLING = 'is_enabling', +} + +export interface CardConfigReturn { + title: string + variant: 'blue' | 'violet' + tooltip: string | ReactElement +} diff --git a/apps/web/src/views/Idos/components/IfoLayout.tsx b/apps/web/src/views/Idos/components/IfoLayout.tsx new file mode 100644 index 0000000000000..c082ef5f236b9 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoLayout.tsx @@ -0,0 +1,20 @@ +import { Box } from '@pancakeswap/uikit' +import { styled } from 'styled-components' + +const IfoLayout = styled(Box)` + > div:not(.sticky-header) { + margin-bottom: 32px; + } +` +export const IfoLayoutWrapper = styled(IfoLayout)` + column-gap: 32px; + display: grid; + grid-template-columns: 1fr; + align-items: flex-start; + + > div { + margin: 0 auto; + } +` + +export default IfoLayout diff --git a/apps/web/src/views/Idos/components/IfoPoolVaultCard.tsx b/apps/web/src/views/Idos/components/IfoPoolVaultCard.tsx new file mode 100644 index 0000000000000..3e4d00542583e --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoPoolVaultCard.tsx @@ -0,0 +1,49 @@ +import { ChainId } from '@pancakeswap/chains' +import { isCakeVaultSupported } from '@pancakeswap/pools' +import { Flex } from '@pancakeswap/uikit' +import { useMemo } from 'react' +import { Address } from 'viem' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { isCrossChainIfoSupportedOnly } from '@pancakeswap/ifos' +import { useActiveIfoConfig } from 'hooks/useIfoConfig' +import { CrossChainVeCakeCard } from './CrossChainVeCakeCard' +import IfoVesting from './IfoVesting/index' +import { VeCakeCard } from './VeCakeCard' + +type Props = { + ifoBasicSaleType?: number + ifoAddress?: Address + ifoChainId?: ChainId +} + +const IfoPoolVaultCard = ({ ifoBasicSaleType, ifoAddress }: Props) => { + const { chainId } = useActiveChainId() + const { activeIfo } = useActiveIfoConfig() + + const targetChainId = useMemo(() => activeIfo?.chainId || chainId, [activeIfo, chainId]) + const cakeVaultSupported = useMemo(() => isCakeVaultSupported(targetChainId), [targetChainId]) + + const vault = useMemo( + () => + cakeVaultSupported ? ( + + ) : isCrossChainIfoSupportedOnly(targetChainId) ? ( + + ) : null, + [targetChainId, cakeVaultSupported, ifoAddress], + ) + + return ( + + {vault} + + {/* Note: Only show when user is connected to BSC for now. + When CrossChain IFO is moved to finished, can enable this again for all chains */} + {chainId === ChainId.BSC && } + + ) +} + +export default IfoPoolVaultCard diff --git a/apps/web/src/views/Idos/components/IfoPoolVaultCardMobile.tsx b/apps/web/src/views/Idos/components/IfoPoolVaultCardMobile.tsx new file mode 100644 index 0000000000000..b3a56d655624e --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoPoolVaultCardMobile.tsx @@ -0,0 +1,99 @@ +import { + Balance, + Box, + Card, + CardHeader, + ExpandableButton, + Flex, + Text, + TokenPairImage as UITokenPairImage, +} from '@pancakeswap/uikit' +import { Pool } from '@pancakeswap/widgets-internal' +import { styled } from 'styled-components' +import { useAccount } from 'wagmi' + +import { useTranslation } from '@pancakeswap/localization' +import { Token } from '@pancakeswap/sdk' +import { getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { vaultPoolConfig } from 'config/constants/pools' +import { useIfoCredit, useVaultPoolByKey } from 'state/pools/hooks' +import { VaultKey } from 'state/types' +import { useConfig } from 'views/Ifos/contexts/IfoContext' +import { CakeVaultDetail } from 'views/Pools/components/CakeVaultCard' + +const StyledCardMobile = styled(Card)` + max-width: 400px; + width: 100%; +` + +const StyledTokenContent = styled(Flex)` + ${Text} { + line-height: 1.2; + white-space: nowrap; + } +` + +interface IfoPoolVaultCardMobileProps { + pool?: Pool.DeserializedPool +} + +const IfoPoolVaultCardMobile: React.FC> = ({ pool }) => { + const { t } = useTranslation() + const { address: account } = useAccount() + const credit = useIfoCredit() + const { isExpanded, setIsExpanded } = useConfig() + const cakeAsNumberBalance = getBalanceNumber(credit) + + const vaultPool = useVaultPoolByKey(pool?.vaultKey || VaultKey.CakeVault) + + const { userData, fees } = vaultPool + const { userShares, isLoading: isVaultUserDataLoading } = userData ?? {} + const { performanceFeeAsDecimal } = fees ?? {} + + const accountHasSharesStaked = userShares && userShares.gt(0) + const isLoading = !pool?.userData || isVaultUserDataLoading + + if (!pool) { + return null + } + + return ( + + + + + + + + {vaultPoolConfig[VaultKey.CakeVault].name} + + + {vaultPoolConfig[VaultKey.CakeVault].description} + + + + + + {t('iCAKE')} + + + + setIsExpanded((prev) => !prev)} /> + + + {isExpanded && ( + + )} + + ) +} + +export default IfoPoolVaultCardMobile diff --git a/apps/web/src/views/Idos/components/IfoQuestions/config.tsx b/apps/web/src/views/Idos/components/IfoQuestions/config.tsx new file mode 100644 index 0000000000000..669c99873ec2b --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoQuestions/config.tsx @@ -0,0 +1,146 @@ +import Trans from 'components/Trans' +import { styled } from 'styled-components' +import { Link, Box } from '@pancakeswap/uikit' + +const InlineLink = styled(Link)` + display: inline; +` + +const config = [ + { + title: What are the sale types? What are the differences between them?, + description: [ + In the current IFO format. There are three types of sales:, +
    +
  • + Public Sales +
  • +
  • + Private Sales +
  • +
  • + Basic Sales +
  • +
, + + There is NO requirement for participating in the Basic Sales. + , + + + To participate in Private Sales, participants will have to meet certain requirements presented on the IFO + card. Each eligible participant will be able to commit any amount of CAKE up to the maximum commit limit, + which is published along with the IFO voting proposal. + + , + + + In the Public Sale, everyone with an active PancakeSwap profile can commit. However the maximum amount of CAKE + users can commit, is equal to the number of iCAKE they have. + + , + + Learn more about iCAKE + + here + + , + + And there’s a fee for participation: see below. + , + ], + }, + { + title: How can I get more iCAKE?, + description: [ + + Your iCAKE number for each IFOs is calculated based on your veCAKE balance at the snapshot time of each IFOs. + Usually the snapshot time is the end time of each IFOs. Therefore, iCAKE can varies between different IFOs. + , + + + To get more iCAKE, simply get more veCAKE by locking more CAKE in your veCAKE position, or extending your + veCAKE position. + + , + ], + }, + { + title: Which sale should I commit to? Can I do both?, + description: [ + You can choose one or both at the same time!, + + + We recommend you to check if you are eligible to participate in the Private Sale first. In the Public Sale, if + the amount you commit is too small, you may not receive a meaningful amount of IFO tokens. + + , + + + Just remember: you need an active PancakeSwap Profile in order to participate in Private and Public Sales. + + , + ], + }, + { + title: How much is the participation fee?, + description: [ + There are two types of participation fee:, +
    +
  • + Cliff +
  • +
  • + Fixed +
  • +
, + + + In “Cliff” model, the participation fee decreases in cliffs, based on the percentage of overflow from the + “Public Sale” portion of the IFO. In “Fixed” modal, participation fee is fixed. + + , + + + Fees may vary between different IFOs. To learn more about the participation fees, please refer to the details + in the IFO proposal (vote) for the specifics of the IFO you want to take part in. + + , + ], + }, + { + title: Where does the participation fee go?, + description: [The CAKE from the participation fee will be burnt as part of the weekly token burn.], + }, + { + title: How can I get an achievement for participating in the IFO?, + description: [ + + You need to contribute a minimum of about 10 USD worth of CAKE to either sale. You can contribute to one or + both, it doesn’t matter: only your overall contribution is counted for the achievement. + , + + Note that only BNB Chain IFOs are eligible for achievements. + , + ], + }, + { + title: What is the difference between an IFO and a cIFO?, + description: [ + + cIFOs are a new subtype of IFOs, designed to reward our loyal community, and also introduce our community to + projects with slightly smaller raises. + , + + Learn more about cIFO + + here + + , + ], + }, +] +export default config diff --git a/apps/web/src/views/Idos/components/IfoQuestions/index.tsx b/apps/web/src/views/Idos/components/IfoQuestions/index.tsx new file mode 100644 index 0000000000000..3ace84e8f292a --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoQuestions/index.tsx @@ -0,0 +1,81 @@ +/* eslint-disable react/no-array-index-key */ +import { styled } from 'styled-components' +import { Text, Heading, Card, CardHeader, CardBody, Flex, Image, Container } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import FoldableText from 'components/FoldableSection/FoldableText' +import { useMemo } from 'react' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { isIfoSupported } from '@pancakeswap/ifos' +import { ChainId } from '@pancakeswap/sdk' + +import { getChainBasedImageUrl } from 'views/Ifos/helpers' + +import config from './config' + +const ImageWrapper = styled.div` + flex: none; + order: 2; + max-width: 414px; + width: 100%; + + ${({ theme }) => theme.mediaQueries.md} { + order: 1; + margin-top: 4rem; + } +` + +const DetailsWrapper = styled.div` + order: 1; + margin-bottom: 40px; + + ${({ theme }) => theme.mediaQueries.md} { + order: 2; + margin-bottom: 0; + margin-left: 40px; + } +` + +const IfoQuestions = () => { + const { t } = useTranslation() + const { chainId: currentChainId } = useActiveChainId() + const bunnyImageUrl = useMemo(() => { + const chainId = isIfoSupported(currentChainId) ? currentChainId : ChainId.BSC + return getChainBasedImageUrl({ chainId, name: 'faq-bunny' }) + }, [currentChainId]) + + return ( + + + + ifo faq bunny + + + + + + {t('Details')} + + + + {config.map(({ title, description }, i, { length }) => { + return ( + + {description.map((desc, index) => { + return ( + + {desc} + + ) + })} + + ) + })} + + + + + + ) +} + +export default IfoQuestions diff --git a/apps/web/src/views/Idos/components/IfoSteps.tsx b/apps/web/src/views/Idos/components/IfoSteps.tsx new file mode 100644 index 0000000000000..f6fe0acb52e5a --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoSteps.tsx @@ -0,0 +1,391 @@ +import { ChainId, Currency, CurrencyAmount } from '@pancakeswap/sdk' +import { + Balance, + Box, + Button, + Card, + CardBody, + CheckmarkIcon, + Container, + Flex, + FlexGap, + Heading, + Link, + LogoRoundIcon, + Skeleton, + Step, + StepStatus, + Stepper, + Text, +} from '@pancakeswap/uikit' +import { Ifo, NextLinkFromReactRouter as RouterLink } from '@pancakeswap/widgets-internal' +import every from 'lodash/every' +import { ReactNode, useMemo } from 'react' +import { styled } from 'styled-components' +import { useAccount } from 'wagmi' + +import { useTranslation } from '@pancakeswap/localization' +import ConnectWalletButton from 'components/ConnectWalletButton' +import { useCakePrice } from 'hooks/useCakePrice' +import { useProfile } from 'state/profile/hooks' + +import { Address } from 'viem' +import { useChainName } from '../hooks/useChainNames' + +interface TypeProps { + sourceChainIfoCredit?: CurrencyAmount + dstChainIfoCredit?: CurrencyAmount + srcChainId?: ChainId + ifoChainId?: ChainId + ifoCurrencyAddress: Address + hasClaimed: boolean + isCommitted: boolean + isLive?: boolean + isFinished?: boolean + isCrossChainIfo?: boolean + hasBridged?: boolean +} + +const SmallStakePoolCard = styled(Box)` + margin-top: 16px; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + background-color: ${({ theme }) => theme.colors.background}; +` + +const Wrapper = styled(Container)` + margin-left: -16px; + margin-right: -16px; + padding-top: 48px; + padding-bottom: 48px; + + ${({ theme }) => theme.mediaQueries.sm} { + margin-left: -24px; + margin-right: -24px; + } +` + +function ICakeCard({ + icon, + title, + credit, + more, + action, +}: { + action?: ReactNode + icon?: ReactNode + title?: ReactNode + credit?: CurrencyAmount + more?: ReactNode +}) { + const balanceNumber = useMemo(() => credit && Number(credit.toExact()), [credit]) + + return ( + + + + {icon} + + + {title} + + + {more} + + + {action} + + + ) +} + +const Step1 = ({ + hasProfile, + sourceChainIfoCredit, + isCrossChainIfo, +}: { + srcChainId?: ChainId + hasProfile: boolean + sourceChainIfoCredit?: CurrencyAmount + isCrossChainIfo?: boolean +}) => { + const { t } = useTranslation() + const cakePrice = useCakePrice() + const balanceNumber = useMemo( + () => sourceChainIfoCredit && Number(sourceChainIfoCredit.toExact()), + [sourceChainIfoCredit], + ) + const creditDollarValue = cakePrice.multipliedBy(balanceNumber ?? 1).toNumber() + + return ( + + + {t('Lock CAKE in the BNB Chain CAKE Staking')} + + + + {t( + 'The maximum amount of CAKE you can commit to the Public Sale equals the number of your iCAKE, which is based on your veCAKE balance at the snapshot time of each IFO. Lock more CAKE for longer durations to increase the maximum CAKE you can commit to the sale.', + )} + + + {t('How is the number of iCAKE calculated?')} + + + {t('Missed this IFO? Lock CAKE today for the next IFO, while enjoying a wide range of veCAKE benefits!')} + + + {hasProfile && ( + 0n)} + /> + } + credit={sourceChainIfoCredit} + title={t('Your ICAKE %iCakeSuffix%', { iCakeSuffix: isCrossChainIfo ? 'on BNB' : '' })} + more={ + + {creditDollarValue !== undefined ? ( + + ) : ( + + )} + + } + action={ + + + + } + /> + )} + + ) +} + +const Step2 = ({ + hasProfile, + isLive, + isCommitted, + isCrossChainIfo, +}: { + hasProfile: boolean + isLive: boolean + isCommitted: boolean + isCrossChainIfo?: boolean +}) => { + const { t } = useTranslation() + return ( + + + {isCrossChainIfo ? t('Switch network and commit CAKE') : t('Commit CAKE')} + + + {isCrossChainIfo + ? t( + 'When the IFO sales are live, you can switch the network to the blockchain where the IFO is hosted on, click “commit” to commit CAKE and buy the tokens being sold.', + ) + : t('When the IFO sales are live, you can click “commit” to commit CAKE and buy the tokens being sold.')} + + + {t('You will need a separate amount of CAKE in your wallet balance to commit to the IFO sales.')} + + {hasProfile && isLive && !isCommitted && ( + + )} + + ) +} + +const IfoSteps: React.FC> = ({ + dstChainIfoCredit, + sourceChainIfoCredit, + srcChainId, + ifoChainId, + isCommitted, + hasClaimed, + isLive, + isFinished, + isCrossChainIfo, + hasBridged, +}) => { + const { hasActiveProfile } = useProfile() + const { address: account } = useAccount() + const { t } = useTranslation() + const ifoChainName = useChainName(ifoChainId) + const sourceChainHasICake = useMemo( + () => sourceChainIfoCredit && sourceChainIfoCredit.quotient > 0n, + [sourceChainIfoCredit], + ) + const stepsValidationStatus = isCrossChainIfo + ? [hasActiveProfile, sourceChainHasICake, hasBridged, isCommitted, hasClaimed] + : [hasActiveProfile, sourceChainHasICake, isCommitted, hasClaimed] + + const getStatusProp = (index: number): StepStatus => { + const arePreviousValid = index === 0 ? true : every(stepsValidationStatus.slice(0, index), Boolean) + if (stepsValidationStatus[index]) { + return arePreviousValid ? 'past' : 'future' + } + return arePreviousValid ? 'current' : 'future' + } + + const renderCardBody = (step: number) => { + const isStepValid = stepsValidationStatus[step] + + const renderAccountStatus = () => { + if (!account) { + return + } + + if (isStepValid) { + return ( + + + {t('Profile Active!')} + + + + ) + } + + return ( + + ) + } + + const renderCommitCakeStep = () => ( + + ) + const renderClaimStep = () => ( + + + {t('Claim your tokens')} + + + {isCrossChainIfo + ? t( + 'After the IFO sales finish, you can switch the network to the blockchain where the IFO is hosted on, claim any IFO tokens that you bought, and any unspent CAKE.', + ) + : t('After the IFO sales finish, you can claim any IFO tokens that you bought, and any unspent CAKE.')} + + + ) + const renderBridge = () => ( + + + {t('Bridge iCAKE')} + + + {t( + 'To participate in the cross chain Public Sale, you need to bridge your veCAKE to the blockchain where the IFO will be hosted on.', + )} + + + {t( + 'Before or during the sale, you may bridge your veCAKE again if you’ve added more CAKE or extended your lock staking position.', + )} + + {sourceChainHasICake && ( + } + credit={dstChainIfoCredit} + title={t('Your iCAKE on %chainName%', { chainName: ifoChainName })} + action={ + !isStepValid && !isFinished ? ( + + ) : null + } + /> + )} + + ) + + switch (step) { + case 0: + return ( + + + {t('Activate your Profile on BNB Chain')} + + + {isCrossChainIfo + ? t('You’ll need an active PancakeSwap Profile to take part in the IFO’s Public Sale!') + : t('You’ll need an active PancakeSwap Profile to take part in an IFO!')} + + {renderAccountStatus()} + + ) + case 1: + return ( + + ) + case 2: + if (isCrossChainIfo) { + return renderBridge() + } + return renderCommitCakeStep() + case 3: + if (isCrossChainIfo) { + return renderCommitCakeStep() + } + return renderClaimStep() + case 4: + return renderClaimStep() + default: + return null + } + } + + return ( + + + {t('How to Take Part in the Public Sale')} + + + {stepsValidationStatus.map((_, index) => ( + + {renderCardBody(index)} + + ))} + + + ) +} + +export default IfoSteps diff --git a/apps/web/src/views/Idos/components/IfoVesting/NotTokens.tsx b/apps/web/src/views/Idos/components/IfoVesting/NotTokens.tsx new file mode 100644 index 0000000000000..011c81ca0e166 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/NotTokens.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from '@pancakeswap/localization' +import { Flex, Text, BunnyPlaceholderIcon } from '@pancakeswap/uikit' +import NextLink from 'next/link' +import { MessageTextLink } from '../IfoCardStyles' + +const NotTokens: React.FC = () => { + const { t } = useTranslation() + + return ( + + + + + {t('You have no tokens available for claiming')} + + + {t('Participate in our next IFO. and remember to lock your CAKE to increase your allocation!')} + + + + {t('How does it work?')} » + + + + + ) +} + +export default NotTokens diff --git a/apps/web/src/views/Idos/components/IfoVesting/VestingEnded.tsx b/apps/web/src/views/Idos/components/IfoVesting/VestingEnded.tsx new file mode 100644 index 0000000000000..1338366894f62 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/VestingEnded.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from '@pancakeswap/localization' +import { Text } from '@pancakeswap/uikit' + +const VestingEnded: React.FC = () => { + const { t } = useTranslation() + + return ( + + {t('You have claimed all available token.')} + + ) +} + +export default VestingEnded diff --git a/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Claim.tsx b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Claim.tsx new file mode 100644 index 0000000000000..ff3d0b3e7a047 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Claim.tsx @@ -0,0 +1,96 @@ +import { PoolIds } from '@pancakeswap/ifos' +import { useTranslation } from '@pancakeswap/localization' +import { AutoRenewIcon, Button, useToast } from '@pancakeswap/uikit' +import { useWeb3React } from '@pancakeswap/wagmi' +import { ToastDescriptionWithTx } from 'components/Toast' +import useCatchTxError from 'hooks/useCatchTxError' +import { useIfoV3Contract } from 'hooks/useContract' +import { useCallback, useMemo } from 'react' +import { Address } from 'viem' +import { VestingData } from 'views/Ifos/hooks/vesting/fetchUserWalletIfoData' + +import { SwitchNetworkTips } from '../../IfoFoldableCard/IfoPoolCard/SwitchNetworkTips' + +interface Props { + poolId: PoolIds + data: VestingData + claimableAmount: string + isVestingInitialized: boolean + fetchUserVestingData: () => void +} + +const ClaimButton: React.FC> = ({ + poolId, + data, + claimableAmount, + isVestingInitialized, + fetchUserVestingData, +}) => { + const { account, chain } = useWeb3React() + const { t } = useTranslation() + const { toastSuccess } = useToast() + const { address, token, chainId } = data.ifo + const contract = useIfoV3Contract(address) + const { fetchWithCatchTxError, loading: isPending } = useCatchTxError() + + const isReady = useMemo(() => { + const checkClaimableAmount = isVestingInitialized ? claimableAmount === '0' : false + return isPending || checkClaimableAmount + }, [isPending, isVestingInitialized, claimableAmount]) + + const handleClaim = useCallback(async () => { + const { vestingId } = data.userVestingData[poolId] + + if (!account) return + + const methods = isVestingInitialized + ? contract?.write.release([vestingId as Address], { account, chain }) + : contract?.write.harvestPool([poolId === PoolIds.poolBasic ? 0 : 1], { account, chain }) + const receipt = await fetchWithCatchTxError(() => { + if (methods) { + return methods + } + throw new Error('Invalid contract call') + }) + + if (receipt?.status) { + toastSuccess( + t('Success!'), + + {t('You have successfully claimed available tokens.')} + , + ) + fetchUserVestingData() + } + }, [ + data.userVestingData, + poolId, + isVestingInitialized, + contract?.write, + account, + chain, + fetchWithCatchTxError, + toastSuccess, + t, + fetchUserVestingData, + ]) + + if (chain?.id !== chainId) { + return + } + + return ( + + ) +} + +export default ClaimButton diff --git a/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Expand.tsx b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Expand.tsx new file mode 100644 index 0000000000000..e3bdccd3f6ce4 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Expand.tsx @@ -0,0 +1,93 @@ +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { styled, keyframes, css } from 'styled-components' +import { useTranslation } from '@pancakeswap/localization' +import { Box, Text } from '@pancakeswap/uikit' +import { VestingData } from 'views/Ifos/hooks/vesting/fetchUserWalletIfoData' +import { PoolIds } from '@pancakeswap/ifos' +import { useIfoConfigAcrossChainsById } from 'hooks/useIfoConfig' + +import Info from './Info' + +const expandAnimation = keyframes` + from { + opacity: 0; + max-height: 0px; + } + to { + opacity: 1; + max-height: 548px; + } +` + +const collapseAnimation = keyframes` + from { + opacity: 1; + max-height: 548px; + } + to { + opacity: 0; + max-height: 0px; + } +` + +const StyledExpand = styled(Box)<{ expanded: boolean }>` + position: relative; + z-index: 0; + opacity: 1; + animation: ${({ expanded }) => + expanded + ? css` + ${expandAnimation} 300ms linear forwards + ` + : css` + ${collapseAnimation} 300ms linear forwards + `}; + overflow: ${({ expanded }) => (expanded ? 'auto' : 'hidden')}; + margin: 0 -24px; + padding: 24px; + background: ${({ theme }) => theme.colors.dropdown}; +` + +interface ExpandProps { + data: VestingData + expanded: boolean + fetchUserVestingData: () => void + ifoBasicSaleType?: number +} + +const Expand: React.FC> = ({ + data, + expanded, + fetchUserVestingData, + ifoBasicSaleType, +}) => { + const { t } = useTranslation() + const { id, token } = data.ifo + const ifoConfig = useIfoConfigAcrossChainsById(id) + const ifoIsActive = useMemo(() => ifoConfig?.isActive, [ifoConfig]) + const router = useRouter() + + const handleViewIfo = () => { + router.push(`/ifo/history#${token.symbol.toLowerCase()}`) + } + + return ( + + + + {!ifoIsActive && ( + + {t('View IFO')} + + )} + + ) +} + +export default Expand diff --git a/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Info.tsx b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Info.tsx new file mode 100644 index 0000000000000..4341072af6b96 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/Info.tsx @@ -0,0 +1,182 @@ +import { useMemo } from 'react' +import { styled } from 'styled-components' +import { useTranslation } from '@pancakeswap/localization' +import { Flex, Text, Progress, Tag } from '@pancakeswap/uikit' +import { VestingData } from 'views/Ifos/hooks/vesting/fetchUserWalletIfoData' +import { PoolIds } from '@pancakeswap/ifos' +import { getFullDisplayBalance } from '@pancakeswap/utils/formatBalance' +import { useCurrentBlock } from 'state/block/hooks' +import useGetPublicIfoV3Data from 'views/Ifos/hooks/v3/useGetPublicIfoData' +import BigNumber from 'bignumber.js' +import dayjs from 'dayjs' + +import { useQuery } from '@tanstack/react-query' +import Claim from './Claim' +import { isBasicSale } from '../../../hooks/v7/helpers' + +const WhiteCard = styled.div` + background: ${({ theme }) => theme.colors.backgroundAlt}; + padding: 12px; + border-radius: 12px; + margin: 8px 0 20px 0; +` + +const StyleTag = styled(Tag)<{ isPrivate: boolean }>` + font-size: 14px; + color: ${({ theme }) => theme.colors.text}; + background: ${({ theme, isPrivate }) => (isPrivate ? theme.colors.gradientBlue : theme.colors.gradientViolet)}; +` + +interface InfoProps { + poolId: PoolIds + data: VestingData + fetchUserVestingData: () => void + ifoBasicSaleType?: number +} + +const Info: React.FC> = ({ + poolId, + data, + fetchUserVestingData, + ifoBasicSaleType, +}) => { + const { t } = useTranslation() + const { token } = data.ifo + const { vestingStartTime } = data.userVestingData + const { + isVestingInitialized, + vestingComputeReleasableAmount, + offeringAmountInToken, + vestingInformationPercentage, + vestingReleased, + vestingInformationDuration, + } = data.userVestingData[poolId] + const labelText = + poolId === PoolIds.poolUnlimited + ? t('Public Sale') + : isBasicSale(ifoBasicSaleType) + ? t('Basic Sale') + : t('Private Sale') + + const currentBlock = useCurrentBlock() + const publicIfoData = useGetPublicIfoV3Data(data.ifo) + const { fetchIfoData: fetchPublicIfoData, isInitialized: isPublicIfoDataInitialized } = publicIfoData + useQuery({ + queryKey: ['fetchPublicIfoData', currentBlock, data?.ifo?.id], + queryFn: async () => fetchPublicIfoData(currentBlock), + enabled: Boolean(!isPublicIfoDataInitialized && currentBlock), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }) + + const { cliff } = publicIfoData[poolId]?.vestingInformation || {} + const currentTimeStamp = Date.now() + const timeCliff = vestingStartTime === 0 ? currentTimeStamp : (vestingStartTime + (cliff ?? 0)) * 1000 + const timeVestingEnd = (vestingStartTime + vestingInformationDuration) * 1000 + const isVestingOver = currentTimeStamp > timeVestingEnd + + const vestingPercentage = useMemo( + () => new BigNumber(vestingInformationPercentage).times(0.01), + [vestingInformationPercentage], + ) + + const releasedAtSaleEnd = useMemo(() => { + return new BigNumber(offeringAmountInToken).times(new BigNumber(1).minus(vestingPercentage)) + }, [offeringAmountInToken, vestingPercentage]) + + const amountReleased = useMemo(() => { + return new BigNumber(releasedAtSaleEnd).plus(vestingReleased).plus(vestingComputeReleasableAmount) + }, [releasedAtSaleEnd, vestingReleased, vestingComputeReleasableAmount]) + + const received = useMemo(() => { + const alreadyClaimed = new BigNumber(releasedAtSaleEnd).plus(vestingReleased) + return alreadyClaimed.gt(0) ? getFullDisplayBalance(alreadyClaimed, token.decimals, 4) : '0' + }, [token, releasedAtSaleEnd, vestingReleased]) + + const claimable = useMemo(() => { + const remain = new BigNumber(offeringAmountInToken).minus(amountReleased) + const claimableAmount = isVestingOver ? vestingComputeReleasableAmount.plus(remain) : vestingComputeReleasableAmount + return claimableAmount.gt(0) ? getFullDisplayBalance(claimableAmount, token.decimals, 4) : '0' + }, [offeringAmountInToken, amountReleased, isVestingOver, vestingComputeReleasableAmount, token.decimals]) + + const remaining = useMemo(() => { + const remain = new BigNumber(offeringAmountInToken).minus(amountReleased) + return remain.gt(0) ? getFullDisplayBalance(remain, token.decimals, 4) : '0' + }, [token, offeringAmountInToken, amountReleased]) + + const percentage = useMemo(() => { + const total = new BigNumber(received).plus(claimable).plus(remaining) + const receivedPercentage = new BigNumber(received).div(total).times(100).toNumber() + const amountAvailablePercentage = new BigNumber(claimable).div(total).times(100).toNumber() + return { + receivedPercentage, + amountAvailablePercentage: receivedPercentage + amountAvailablePercentage, + } + }, [received, claimable, remaining]) + + if (claimable === '0' && remaining === '0') { + return null + } + + return ( + <> + + + {t('Vesting Schedule')} + + {labelText} + + + + {cliff === 0 ? t('Vesting Start') : t('Cliff')} + + + {dayjs(timeCliff).format('MM/DD/YYYY HH:mm')} + + + + + {t('Vesting end')} + + + {dayjs(timeVestingEnd).format('MM/DD/YYYY HH:mm')} + + + + + + + {received} + + {t('Received')} + + + + {claimable} + + {t('Claimable')} + + + + + {isVestingOver ? '-' : remaining} + + + {t('Remaining')} + + + + + + + ) +} + +export default Info diff --git a/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/TokenInfo.tsx b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/TokenInfo.tsx new file mode 100644 index 0000000000000..fc1cc9a6e262f --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/VestingPeriod/TokenInfo.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState, useMemo } from 'react' +import { styled } from 'styled-components' +import BigNumber from 'bignumber.js' +import { Box, Flex, Text, ChevronDownIcon, BalanceWithLoading } from '@pancakeswap/uikit' +import { TokenImage } from 'components/TokenImage' +import { VestingData } from 'views/Ifos/hooks/vesting/fetchUserWalletIfoData' +import { PoolIds } from '@pancakeswap/ifos' +import { getBalanceNumber } from '@pancakeswap/utils/formatBalance' +import { useStablecoinPrice } from 'hooks/useStablecoinPrice' +import { multiplyPriceByAmount } from 'utils/prices' +import { useDelayedUnmount } from '@pancakeswap/hooks' +import Expand from './Expand' + +const ArrowIcon = styled(ChevronDownIcon)<{ $toggled: boolean }>` + transform: ${({ $toggled }) => ($toggled ? 'rotate(180deg)' : 'rotate(0)')}; + height: 24px; +` + +interface TokenInfoProps { + index: number + data: VestingData + fetchUserVestingData: () => void + ifoBasicSaleType?: number +} + +const TokenInfo: React.FC> = ({ + index, + data, + fetchUserVestingData, + ifoBasicSaleType, +}) => { + const { vestingTitle, token } = data.ifo + const { vestingComputeReleasableAmount } = data.userVestingData[PoolIds.poolUnlimited] + const { vestingComputeReleasableAmount: basicReleaseAmount } = data.userVestingData[PoolIds.poolBasic] + const [expanded, setExpanded] = useState(false) + const shouldRenderExpand = useDelayedUnmount(expanded, 300) + + useEffect(() => { + if (index === 0) { + setExpanded(true) + } + }, [index]) + + const toggleExpanded = () => { + setExpanded((prev) => !prev) + } + + const amountAvailable = useMemo(() => { + const totalReleaseAmount = new BigNumber(vestingComputeReleasableAmount).plus(basicReleaseAmount) + return getBalanceNumber(totalReleaseAmount, token.decimals) + }, [token, vestingComputeReleasableAmount, basicReleaseAmount]) + + const price = useStablecoinPrice(token) + const dollarValueOfToken = multiplyPriceByAmount(price, amountAvailable, token.decimals) + + return ( + + + + + + {vestingTitle} + + + + + {token.symbol} ~${dollarValueOfToken.toFixed(2)} + + + + + + {shouldRenderExpand && ( + + )} + + ) +} + +export default TokenInfo diff --git a/apps/web/src/views/Idos/components/IfoVesting/index.tsx b/apps/web/src/views/Idos/components/IfoVesting/index.tsx new file mode 100644 index 0000000000000..8db2c8d604dd5 --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/index.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState, useCallback, useEffect } from 'react' +import { styled } from 'styled-components' +import { useTranslation } from '@pancakeswap/localization' +import { Box, Card, CardBody, CardHeader, Flex, Text, Image, IfoNotTokens } from '@pancakeswap/uikit' +import { useAccount } from 'wagmi' + +import Trans from 'components/Trans' + +import { VestingStatus } from './types' +import TokenInfo from './VestingPeriod/TokenInfo' +import VestingEnded from './VestingEnded' +import useFetchVestingData from '../../hooks/vesting/useFetchVestingData' + +const StyleVestingCard = styled(Card)` + width: 100%; + max-width: 400px; + margin: 24px 0 0 0; + align-self: baseline; + ${({ theme }) => theme.mediaQueries.xl} { + max-width: 350px; + margin: 0 12px 0 12px; + } +` + +const VestingCardBody = styled(CardBody)` + position: relative; + z-index: 2; + overflow-y: auto; + max-height: 640px; + padding-bottom: 0; + border-radius: 0 0 24px 24px; +` + +const TokenInfoContainer = styled.div` + > div { + margin-bottom: 20px; + } + + > :last-child { + margin-bottom: 0px; + } +` + +const IfoVestingStatus = { + [VestingStatus.NOT_TOKENS_CLAIM]: { + status: VestingStatus.NOT_TOKENS_CLAIM, + text: You have no tokens available for claiming, + imgUrl: '/images/ifos/vesting/not-tokens.svg', + }, + [VestingStatus.HAS_TOKENS_CLAIM]: { + status: VestingStatus.HAS_TOKENS_CLAIM, + text: You have tokens available for claiming now!, + imgUrl: '/images/ifos/vesting/in-vesting-period.svg', + }, + [VestingStatus.ENDED]: { + status: VestingStatus.ENDED, + text: No vesting token to claim., + imgUrl: '/images/ifos/vesting/in-vesting-end.svg', + }, +} + +interface IfoVestingProps { + ifoBasicSaleType?: number +} + +const IfoVesting: React.FC> = ({ ifoBasicSaleType }: IfoVestingProps) => { + const { t } = useTranslation() + const { address: account } = useAccount() + const [isFirstTime, setIsFirstTime] = useState(true) + const { data, fetchUserVestingData } = useFetchVestingData() + + useEffect(() => { + // When switch account need init + if (account) { + setIsFirstTime(true) + fetchUserVestingData() + } + }, [account, fetchUserVestingData, setIsFirstTime]) + + const cardStatus = useMemo(() => { + if (account) { + if (data.length > 0) return IfoVestingStatus[VestingStatus.HAS_TOKENS_CLAIM] + if (data.length === 0 && !isFirstTime) return IfoVestingStatus[VestingStatus.ENDED] + } + return IfoVestingStatus[VestingStatus.NOT_TOKENS_CLAIM] + }, [data, account, isFirstTime]) + + const handleFetchUserVesting = useCallback(() => { + setIsFirstTime(false) + fetchUserVestingData() + }, [fetchUserVestingData]) + + return ( + + + + + + {t('Token Vesting')} + + + {cardStatus.text} + + + ifo-vesting-status + + + + {cardStatus.status === VestingStatus.NOT_TOKENS_CLAIM && ( + + )} + {cardStatus.status === VestingStatus.HAS_TOKENS_CLAIM && ( + + {data.map((ifo, index) => ( + + ))} + + )} + {cardStatus.status === VestingStatus.ENDED && } + + + ) +} + +export default IfoVesting diff --git a/apps/web/src/views/Idos/components/IfoVesting/types.ts b/apps/web/src/views/Idos/components/IfoVesting/types.ts new file mode 100644 index 0000000000000..b9bb1d3f3da7e --- /dev/null +++ b/apps/web/src/views/Idos/components/IfoVesting/types.ts @@ -0,0 +1,5 @@ +export enum VestingStatus { + NOT_TOKENS_CLAIM = 'NOT_TOKENS_CLAIM', + HAS_TOKENS_CLAIM = 'HAS_TOKENS_CLAIM', + ENDED = 'ENDED', +} diff --git a/apps/web/src/views/Idos/components/SectionBackground.tsx b/apps/web/src/views/Idos/components/SectionBackground.tsx new file mode 100644 index 0000000000000..a5a370b29570d --- /dev/null +++ b/apps/web/src/views/Idos/components/SectionBackground.tsx @@ -0,0 +1,6 @@ +import { styled } from 'styled-components' +import { Box } from '@pancakeswap/uikit' + +export const SectionBackground = styled(Box)` + background: ${({ theme }) => theme.colors.gradientBubblegum}; +` diff --git a/apps/web/src/views/Idos/components/TransWithElement.tsx b/apps/web/src/views/Idos/components/TransWithElement.tsx new file mode 100644 index 0000000000000..6b228509ea7aa --- /dev/null +++ b/apps/web/src/views/Idos/components/TransWithElement.tsx @@ -0,0 +1,23 @@ +import { useMemo, ReactElement } from 'react' + +interface Props { + text: string + element: ReactElement + keyword: string +} + +const TransWithElement: React.FC> = ({ text, element, keyword }) => { + const [head, tail] = useMemo(() => { + return text.split(keyword) + }, [text, keyword]) + + return ( + <> + {head} + {element} + {tail} + + ) +} + +export default TransWithElement diff --git a/apps/web/src/views/Idos/components/VeCakeCard.tsx b/apps/web/src/views/Idos/components/VeCakeCard.tsx new file mode 100644 index 0000000000000..ebdfbb3dde92f --- /dev/null +++ b/apps/web/src/views/Idos/components/VeCakeCard.tsx @@ -0,0 +1,109 @@ +import { Ifo } from '@pancakeswap/widgets-internal' +import { ChainId } from '@pancakeswap/chains' +import { Button } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import Link from 'next/link' +import { SpaceProps } from 'styled-system' +import { useAccount } from 'wagmi' +import { Address } from 'viem' +import { useMemo } from 'react' +import BigNumber from 'bignumber.js' +import { CAKE } from '@pancakeswap/tokens' +import { formatBigInt } from '@pancakeswap/utils/formatBalance' + +import { useCakePrice } from 'hooks/useCakePrice' +import { useActiveChainId } from 'hooks/useActiveChainId' +import ConnectWalletButton from 'components/ConnectWalletButton' + +// TODO should be common hooks +import { useCakeLockStatus } from 'views/CakeStaking/hooks/useVeCakeUserInfo' +import { useIsMigratedToVeCake } from 'views/CakeStaking/hooks/useIsMigratedToVeCake' +import { useIsUserDelegated } from 'views/CakeStaking/hooks/useIsUserDelegated' + +import { useUserIfoInfo } from '../hooks/useUserIfoInfo' + +function NavigateButton(props: SpaceProps) { + const { t } = useTranslation() + + return ( + + ) +} + +type Props = { + ifoAddress?: Address +} + +export function VeCakeCard({ ifoAddress }: Props) { + const { chainId } = useActiveChainId() + const { isConnected } = useAccount() + const cakePrice = useCakePrice() + const isUserDelegated = useIsUserDelegated() + const { + cakeUnlockTime: nativeUnlockTime, + nativeCakeLockedAmount, + proxyCakeLockedAmount, + cakePoolLocked: proxyLocked, + cakePoolUnlockTime: proxyUnlockTime, + cakeLocked: nativeLocked, + shouldMigrate, + } = useCakeLockStatus() + const isMigrated = useIsMigratedToVeCake() + const needMigrate = useMemo(() => shouldMigrate && !isMigrated, [shouldMigrate, isMigrated]) + const totalLockCake = useMemo( + () => + Number( + formatBigInt( + isUserDelegated ? nativeCakeLockedAmount : nativeCakeLockedAmount + proxyCakeLockedAmount, + CAKE[chainId || ChainId.BSC].decimals, + ), + ), + [nativeCakeLockedAmount, proxyCakeLockedAmount, chainId, isUserDelegated], + ) + const hasProxyCakeButNoNativeVeCake = useMemo(() => !nativeLocked && proxyLocked, [nativeLocked, proxyLocked]) + const unlockAt = useMemo(() => { + if (hasProxyCakeButNoNativeVeCake) { + return proxyUnlockTime + } + return nativeUnlockTime + }, [hasProxyCakeButNoNativeVeCake, nativeUnlockTime, proxyUnlockTime]) + + const { snapshotTime, credit, veCake } = useUserIfoInfo({ ifoAddress, chainId }) + const creditBN = useMemo( + () => credit && new BigNumber(credit.numerator.toString()).div(credit.decimalScale.toString()), + [credit], + ) + const hasICake = useMemo(() => creditBN && creditBN.toNumber() > 0, [creditBN]) + const hasVeCake = useMemo(() => veCake && veCake.toNumber() > 0, [veCake]) + + const header = ( + <> + + + + ) + + return ( + + + + + {isConnected && hasICake && totalLockCake ? ( + + ) : null} + + {isConnected && !hasVeCake ? ( + !needMigrate && hasProxyCakeButNoNativeVeCake && !isUserDelegated ? ( + + ) : ( + + ) + ) : null} + + {needMigrate ? : null} + {isConnected ? : } + + ) +} diff --git a/apps/web/src/views/Idos/components/WarningTips.tsx b/apps/web/src/views/Idos/components/WarningTips.tsx new file mode 100644 index 0000000000000..7d59340c2334b --- /dev/null +++ b/apps/web/src/views/Idos/components/WarningTips.tsx @@ -0,0 +1,40 @@ +import { Box, Message, Flex, Text, InfoFilledIcon } from '@pancakeswap/uikit' +import { SpaceProps } from 'styled-system' +import Link from 'next/link' +import { ReactNode } from 'react' +import styled from 'styled-components' + +type Props = { + action?: ReactNode + title?: ReactNode + content?: ReactNode +} & SpaceProps + +export const LinkTitle = styled(Link).attrs({ scroll: false })` + font-weight: bold; + font-size: 0.875rem; + text-decoration: underline; + color: ${({ theme }) => theme.colors.yellow}; +` + +export const ContentText = styled(Text)` + color: ${({ theme }) => theme.colors.yellow}; + font-size: 0.875rem; +` + +export function WarningTips({ action, title, content, ...props }: Props) { + return ( + } + {...props} + > + + {title} + {content} + + + ) +} diff --git a/apps/web/src/views/Idos/contexts/IfoContext.tsx b/apps/web/src/views/Idos/contexts/IfoContext.tsx new file mode 100644 index 0000000000000..08ae59986480d --- /dev/null +++ b/apps/web/src/views/Idos/contexts/IfoContext.tsx @@ -0,0 +1,25 @@ +import { Dispatch, SetStateAction, createContext, useContext, useMemo, useState } from 'react' + +export type IfoContextState = { + isExpanded: boolean + setIsExpanded: Dispatch> +} +export const IfoContext = createContext(null) + +export function useConfig() { + const ctx = useContext(IfoContext) + + if (!ctx) { + throw new Error('useConfig must be used within a IfoProvider') + } + + return ctx +} + +export default function IfoProvider({ children }) { + const [isExpanded, setIsExpanded] = useState(false) + + const providerValue = useMemo(() => ({ isExpanded, setIsExpanded }), [isExpanded]) + + return {children} +} diff --git a/apps/web/src/views/Idos/helpers/getBannerUrl.ts b/apps/web/src/views/Idos/helpers/getBannerUrl.ts new file mode 100644 index 0000000000000..614ed7b75c7d3 --- /dev/null +++ b/apps/web/src/views/Idos/helpers/getBannerUrl.ts @@ -0,0 +1,5 @@ +import { ASSET_CDN } from 'config/constants/endpoints' + +export function getBannerUrl(ifoId: string) { + return `${ASSET_CDN}/web/ifos/bg/${ifoId}-bg.png` +} diff --git a/apps/web/src/views/Idos/helpers/getChainBasedImageUrl.ts b/apps/web/src/views/Idos/helpers/getChainBasedImageUrl.ts new file mode 100644 index 0000000000000..c66748f38f91d --- /dev/null +++ b/apps/web/src/views/Idos/helpers/getChainBasedImageUrl.ts @@ -0,0 +1,12 @@ +import { ChainId, getChainName } from '@pancakeswap/chains' + +import { ASSET_CDN } from 'config/constants/endpoints' + +type GetUrlOptions = { + chainId?: ChainId + name: string +} + +export function getChainBasedImageUrl({ chainId = ChainId.BSC, name }: GetUrlOptions) { + return `${ASSET_CDN}/web/ifos/${name}/${getChainName(chainId)}.png` +} diff --git a/apps/web/src/views/Idos/helpers/index.ts b/apps/web/src/views/Idos/helpers/index.ts new file mode 100644 index 0000000000000..b70806e907f85 --- /dev/null +++ b/apps/web/src/views/Idos/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './getBannerUrl' +export * from './getChainBasedImageUrl' diff --git a/apps/web/src/views/Idos/hooks/helpers.ts b/apps/web/src/views/Idos/hooks/helpers.ts new file mode 100644 index 0000000000000..ff0e093f00f76 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/helpers.ts @@ -0,0 +1,45 @@ +import { IfoStatus } from '@pancakeswap/ifos' + +export const getStatus = (currentBlock: number, startBlock: number, endBlock: number): IfoStatus => { + // Add an extra check to currentBlock because it takes awhile to fetch so the initial value is 0 + // making the UI change to an inaccurate status + if (currentBlock === 0) { + return 'idle' + } + + if (currentBlock < startBlock) { + return 'coming_soon' + } + + if (currentBlock >= startBlock && currentBlock <= endBlock) { + return 'live' + } + + if (currentBlock > endBlock) { + return 'finished' + } + + return 'idle' +} + +export const getStatusByTimestamp = (now: number, startTimestamp?: number, endTimestamp?: number): IfoStatus => { + if (!startTimestamp || !endTimestamp) { + return 'idle' + } + + if (now < startTimestamp) { + return 'coming_soon' + } + + if (now >= startTimestamp && now <= endTimestamp) { + return 'live' + } + + if (now > endTimestamp) { + return 'finished' + } + + return 'idle' +} + +export default null diff --git a/apps/web/src/views/Idos/hooks/useBridgeICake.ts b/apps/web/src/views/Idos/hooks/useBridgeICake.ts new file mode 100644 index 0000000000000..ddc670e9aae1e --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useBridgeICake.ts @@ -0,0 +1,305 @@ +import { useMemo, useState, useCallback, useEffect } from 'react' +import { ChainId, CurrencyAmount, Currency } from '@pancakeswap/sdk' +import { + INFO_SENDER, + getCrossChainMessageUrl, + CrossChainMessage, + getBridgeICakeGasFee, + getCrossChainMessage, + pancakeInfoSenderABI, + getLayerZeroChainId, + MessageStatus, +} from '@pancakeswap/ifos' +import { useAccount } from 'wagmi' +import { Hash, Address } from 'viem' +import localforage from 'localforage' +import { useQuery } from '@tanstack/react-query' + +import { getBlockExploreLink } from 'utils' +import { getViemClients } from 'utils/viem' +import { useContract } from 'hooks/useContract' +import { useCallWithGasPrice } from 'hooks/useCallWithGasPrice' +import { useTransactionAdder } from 'state/transactions/hooks' +import useCatchTxError from 'hooks/useCatchTxError' +import { isUserRejected } from 'utils/sentry' + +import { usePublicNodeWaitForTransaction } from 'hooks/usePublicNodeWaitForTransaction' +import { useChainName } from './useChainNames' + +export enum BRIDGE_STATE { + // Before start bridging + INITIAL, + + // Pending user sign tx on wallet + PENDING_WALLET_SIGN, + + // Sending tx on source chain + PENDING_SOURCE_CHAIN_TX, + + // After getting receipt on source chain, + // while pending tx on destination chain + PENDING_CROSS_CHAIN_TX, + + // After message got confirmed on destination chain + FINISHED, +} + +export type BaseBridgeState = { + state: BRIDGE_STATE.INITIAL | BRIDGE_STATE.PENDING_WALLET_SIGN | BRIDGE_STATE.PENDING_SOURCE_CHAIN_TX +} + +export type PendingCrossChainState = { + state: BRIDGE_STATE.PENDING_CROSS_CHAIN_TX +} & CrossChainMessage + +export type BridgeSuccessState = { + state: BRIDGE_STATE.FINISHED +} & CrossChainMessage + +export type BridgeState = BaseBridgeState | PendingCrossChainState | BridgeSuccessState + +const INITIAL_BRIDGE_STATE: BridgeState = { + state: BRIDGE_STATE.INITIAL, +} + +type Params = { + ifoId: string + srcChainId: ChainId + ifoChainId: ChainId + + // icake on source chain + icake?: CurrencyAmount + // icake on destination chain + dstIcake?: CurrencyAmount + + // Called if user reject signing bridge tx + onUserReject?: () => void +} + +// NOTE: this hook has side effect +export function useBridgeICake({ srcChainId, ifoChainId, icake, ifoId, dstIcake, onUserReject }: Params) { + const [signing, setSigning] = useState(false) + const sourceChainName = useChainName(srcChainId) + const ifoChainName = useChainName(ifoChainId) + const { address: account } = useAccount() + const { callWithGasPrice } = useCallWithGasPrice() + const addTransaction = useTransactionAdder() + const infoSender = useContract(INFO_SENDER, pancakeInfoSenderABI, { chainId: srcChainId }) + const { receipt, saveTransactionHash, clearTransactionHash, txHash } = useLatestBridgeTx(ifoId, srcChainId) + const message = useCrossChainMessage({ txHash: receipt?.transactionHash, srcChainId }) + const { fetchWithCatchTxError } = useCatchTxError({ throwUserRejectError: true }) + const isICakeSynced = useMemo( + () => icake && dstIcake && icake.quotient === dstIcake.quotient && icake.quotient > 0n, + [icake, dstIcake], + ) + + const bridge = useCallback(async () => { + if (!account) { + return + } + try { + await fetchWithCatchTxError(async () => { + setSigning(true) + const gasEstimate = await getBridgeICakeGasFee({ + srcChainId, + dstChainId: ifoChainId, + account, + provider: getViemClients, + }) + const txReceipt = await callWithGasPrice(infoSender, 'sendSyncMsg', [getLayerZeroChainId(ifoChainId)], { + value: gasEstimate.quotient, + }) + saveTransactionHash(txReceipt.hash) + const summary = `Bridge ${icake?.toExact()} iCAKE from ${sourceChainName} to ${ifoChainName}` + addTransaction(txReceipt, { + summary, + translatableSummary: { + text: 'Bridge %icakeAmount% iCAKE from %srcChain% to %ifoChain%', + data: { + icakeAmount: icake?.toExact() || '', + srcChain: sourceChainName, + ifoChain: ifoChainName, + }, + }, + type: 'bridge-icake', + }) + setSigning(false) + return txReceipt + }) + } catch (e) { + if (isUserRejected(e)) { + onUserReject?.() + return + } + console.error(e) + } finally { + setSigning(false) + } + }, [ + onUserReject, + fetchWithCatchTxError, + saveTransactionHash, + account, + srcChainId, + ifoChainId, + callWithGasPrice, + infoSender, + addTransaction, + icake, + sourceChainName, + ifoChainName, + ]) + + const state = useMemo(() => { + if (!txHash && !signing && !receipt && !message) { + return INITIAL_BRIDGE_STATE + } + if (signing) { + return { + state: BRIDGE_STATE.PENDING_WALLET_SIGN, + } + } + if (txHash && (!receipt || !message)) { + return { + state: BRIDGE_STATE.PENDING_SOURCE_CHAIN_TX, + } + } + if (message && message.status !== MessageStatus.DELIVERED) { + return { + state: BRIDGE_STATE.PENDING_CROSS_CHAIN_TX, + ...message, + } + } + if (message && message.status === MessageStatus.DELIVERED) { + return { + state: BRIDGE_STATE.FINISHED, + ...message, + } + } + return INITIAL_BRIDGE_STATE + }, [signing, receipt, message, txHash]) + + const isBridging = useMemo( + () => state.state !== BRIDGE_STATE.INITIAL && state.state !== BRIDGE_STATE.FINISHED, + [state.state], + ) + + const isBridged = useMemo( + () => isICakeSynced || message?.status === MessageStatus.DELIVERED, + [message?.status, isICakeSynced], + ) + + return { + state, + bridge, + isBridging, + isBridged, + clearBridgeHistory: clearTransactionHash, + } +} + +export function useBridgeMessageUrl(state: BridgeState) { + return useMemo( + () => + state.state === BRIDGE_STATE.PENDING_CROSS_CHAIN_TX || state.state === BRIDGE_STATE.FINISHED + ? getCrossChainMessageUrl(state) + : null, + [state], + ) +} + +export function useBridgeSuccessTxUrl(state: BridgeState) { + return useMemo( + () => + state.state === BRIDGE_STATE.FINISHED && state.dstTxHash + ? getBlockExploreLink(state.dstTxHash, 'transaction', state.dstChainId) + : null, + [state], + ) +} + +const getLastBridgeTxStorageKey = (ifoId: string, chainId?: ChainId, account?: Address) => + chainId && account && `bridge-icake-tx-hash-latest-${account}-${chainId}-${ifoId}` + +export function useLatestBridgeTx(ifoId: string, chainId?: ChainId) { + const { address: account } = useAccount() + const [tx, setTx] = useState(null) + const storageKey = useMemo(() => getLastBridgeTxStorageKey(ifoId, chainId, account), [ifoId, chainId, account]) + + const tryGetTxFromStorage = useCallback(async () => { + if (!storageKey) { + return + } + + try { + const lastTx: Hash | null = await localforage.getItem(storageKey) + if (lastTx) { + setTx(lastTx) + } + } catch (e) { + console.error(e) + } + }, [storageKey]) + + const saveTransactionHash = useCallback( + async (txHash: Hash) => { + setTx(txHash) + if (storageKey) { + await localforage.setItem(storageKey, txHash) + } + }, + [storageKey], + ) + + const clearTransactionHash = useCallback(async () => { + if (storageKey) { + await localforage.removeItem(storageKey) + } + }, [storageKey]) + + const { waitForTransaction } = usePublicNodeWaitForTransaction() + + const { data: receipt } = useQuery({ + queryKey: [tx, 'bridge-icake-tx-receipt'], + queryFn: () => tx && waitForTransaction({ hash: tx, chainId }), + enabled: Boolean(tx && chainId), + }) + + // Get last tx from storage on load + useEffect(() => { + tryGetTxFromStorage() + }, [tryGetTxFromStorage]) + + return { + txHash: tx, + receipt, + saveTransactionHash, + clearTransactionHash, + } +} + +type CrossChainMeesageParams = { + txHash?: Hash | null + srcChainId?: ChainId +} + +export function useCrossChainMessage({ txHash, srcChainId }: CrossChainMeesageParams) { + const { data: message } = useQuery({ + queryKey: [txHash, srcChainId, 'ifo-cross-chain-sync-message'], + + queryFn: () => { + if (!srcChainId || !txHash) { + throw new Error('Invalid srcChainId or tx hash') + } + + return getCrossChainMessage({ + chainId: srcChainId, + txHash, + }) + }, + + enabled: Boolean(txHash && srcChainId), + refetchInterval: 5 * 1000, + }) + return message +} diff --git a/apps/web/src/views/Idos/hooks/useChainNames.ts b/apps/web/src/views/Idos/hooks/useChainNames.ts new file mode 100644 index 0000000000000..4f7bcc9b57e28 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useChainNames.ts @@ -0,0 +1,26 @@ +import { ChainId } from '@pancakeswap/sdk' +import { useMemo } from 'react' +import { chains } from 'utils/wagmi' + +const SHORT_NAME = { + [ChainId.POLYGON_ZKEVM]: 'zkEVM', + [ChainId.BSC]: 'BNB Chain', + [ChainId.ARBITRUM_ONE]: 'Arbitrum', +} + +type ChainNameOptions = { + shortName?: boolean +} + +export function useChainName(chainId?: ChainId, options?: ChainNameOptions) { + const name = useMemo(() => chains.find((chain) => chain.id === chainId)?.name || '', [chainId]) + const shortName = (chainId && SHORT_NAME[chainId]) || name + return options?.shortName ? shortName : name +} + +export function useChainNames(chainIds?: readonly ChainId[] | ChainId[]) { + return useMemo( + () => chainIds?.map((chainId) => chains.find((chain) => chain.id === chainId)?.name)?.join(', ') || '', + [chainIds], + ) +} diff --git a/apps/web/src/views/Idos/hooks/useIfoAllowance.ts b/apps/web/src/views/Idos/hooks/useIfoAllowance.ts new file mode 100644 index 0000000000000..fcce6abc0a683 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useIfoAllowance.ts @@ -0,0 +1,35 @@ +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import BigNumber from 'bignumber.js' +import { useERC20, useTokenContract } from 'hooks/useContract' +import { useEffect, useState } from 'react' +import { Address } from 'viem' +import { useAccount } from 'wagmi' + +// Retrieve IFO allowance +const useIfoAllowance = ( + tokenContract: ReturnType | ReturnType, // TODO: merge hooks + spenderAddress: Address, + dependency?: any, +): BigNumber => { + const { address: account } = useAccount() + const [allowance, setAllowance] = useState(BIG_ZERO) + + useEffect(() => { + const fetch = async () => { + try { + const res = await tokenContract?.read.allowance([account!, spenderAddress]) + setAllowance(new BigNumber(res?.toString() ?? 0)) + } catch (e) { + console.error(e) + } + } + + if (account) { + fetch() + } + }, [account, spenderAddress, tokenContract, dependency]) + + return allowance +} + +export default useIfoAllowance diff --git a/apps/web/src/views/Idos/hooks/useIfoApprove.ts b/apps/web/src/views/Idos/hooks/useIfoApprove.ts new file mode 100644 index 0000000000000..3fe4cc22e4247 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useIfoApprove.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react' +import { MaxUint256 } from '@pancakeswap/swap-sdk-core' +import { Ifo } from '@pancakeswap/ifos' +import { useCallWithGasPrice } from 'hooks/useCallWithGasPrice' +import { useERC20 } from 'hooks/useContract' +import { Address } from 'viem' + +const useIfoApprove = (ifo: Ifo, spenderAddress?: string) => { + const raisingTokenContract = useERC20(ifo.currency.address, { chainId: ifo.chainId }) + const { callWithGasPrice } = useCallWithGasPrice() + const onApprove = useCallback(async () => { + if (!spenderAddress) { + return + } + // eslint-disable-next-line consistent-return + return callWithGasPrice(raisingTokenContract, 'approve', [spenderAddress as Address, MaxUint256]) + }, [spenderAddress, raisingTokenContract, callWithGasPrice]) + + return onApprove +} + +export default useIfoApprove diff --git a/apps/web/src/views/Idos/hooks/useIfoCredit.ts b/apps/web/src/views/Idos/hooks/useIfoCredit.ts new file mode 100644 index 0000000000000..ec30aead197bd --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useIfoCredit.ts @@ -0,0 +1,70 @@ +import { ChainId } from '@pancakeswap/chains' +import { fetchPublicIfoData } from '@pancakeswap/ifos' +import { useQuery } from '@tanstack/react-query' +import BigNumber from 'bignumber.js' +import { useMemo } from 'react' +import { Address } from 'viem' + +import { getViemClients } from 'utils/viem' + +import { useIfoSourceChain } from './useIfoSourceChain' +import { useUserIfoInfo } from './useUserIfoInfo' + +type IfoCreditParams = { + chainId?: ChainId + ifoAddress?: Address +} + +export function useIfoCredit({ chainId, ifoAddress }: IfoCreditParams) { + const { credit } = useUserIfoInfo({ chainId, ifoAddress }) + return credit +} + +export function useIfoCeiling({ chainId }: { chainId?: ChainId }): BigNumber | undefined { + const { data } = useQuery({ + queryKey: [chainId, 'ifo-ceiling'], + queryFn: () => fetchPublicIfoData(chainId, getViemClients), + enabled: !!chainId, + }) + return useMemo(() => (data?.ceiling ? new BigNumber(data.ceiling) : undefined), [data]) +} + +type ICakeStatusParams = { + ifoChainId?: ChainId + ifoAddress?: Address +} + +export function useICakeBridgeStatus({ ifoChainId, ifoAddress }: ICakeStatusParams) { + const srcChainId = useIfoSourceChain(ifoChainId) + + const isCrossChainIfo = useMemo(() => srcChainId !== ifoChainId, [srcChainId, ifoChainId]) + + const destChainCredit = useIfoCredit({ chainId: ifoChainId, ifoAddress }) + + // Ifo address is only on target chain so pass undefined for source chain + const sourceChainCredit = useIfoCredit({ chainId: srcChainId, ifoAddress: isCrossChainIfo ? undefined : ifoAddress }) + + const noICake = useMemo(() => !sourceChainCredit || sourceChainCredit.quotient === 0n, [sourceChainCredit]) + const isICakeSynced = useMemo( + () => destChainCredit && sourceChainCredit && destChainCredit.quotient === sourceChainCredit.quotient, + [sourceChainCredit, destChainCredit], + ) + const shouldBridgeAgain = useMemo( + () => + destChainCredit && + sourceChainCredit && + destChainCredit.quotient > 0n && + sourceChainCredit.quotient !== destChainCredit.quotient, + [destChainCredit, sourceChainCredit], + ) + + return { + srcChainId, + noICake, + isICakeSynced, + shouldBridgeAgain, + sourceChainCredit, + destChainCredit, + hasBridged: !noICake && isICakeSynced, + } +} diff --git a/apps/web/src/views/Idos/hooks/useIfoSourceChain.ts b/apps/web/src/views/Idos/hooks/useIfoSourceChain.ts new file mode 100644 index 0000000000000..410b7fa55fb5c --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useIfoSourceChain.ts @@ -0,0 +1,8 @@ +import { getSourceChain } from '@pancakeswap/ifos' +import { useMemo } from 'react' +import { ChainId } from '@pancakeswap/chains' + +// By deafult source chain is the first chain that supports native ifo +export function useIfoSourceChain(chainId?: ChainId) { + return useMemo(() => getSourceChain(chainId) || ChainId.BSC, [chainId]) +} diff --git a/apps/web/src/views/Idos/hooks/useIfoVesting.ts b/apps/web/src/views/Idos/hooks/useIfoVesting.ts new file mode 100644 index 0000000000000..61dbdb3bcf309 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useIfoVesting.ts @@ -0,0 +1,67 @@ +import { useMemo } from 'react' +import BigNumber from 'bignumber.js' +import { PoolIds } from '@pancakeswap/ifos' +import { PublicIfoData, WalletIfoData } from 'views/Ifos/types' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' + +interface UseIfoVestingProps { + poolId: PoolIds + publicIfoData: PublicIfoData + walletIfoData: WalletIfoData +} + +const useIfoVesting = ({ poolId, publicIfoData, walletIfoData }: UseIfoVestingProps) => { + const publicPool = publicIfoData[poolId] + const userPool = walletIfoData[poolId] + const { vestingStartTime } = publicIfoData + const vestingInformation = publicPool?.vestingInformation + + const isVestingOver = useMemo(() => { + const currentTimeStamp = Date.now() + const timeVestingEnd = + vestingStartTime === 0 ? currentTimeStamp : ((vestingStartTime ?? 0) + (vestingInformation?.duration ?? 0)) * 1000 + return currentTimeStamp > timeVestingEnd + }, [vestingStartTime, vestingInformation]) + + const vestingPercentage = useMemo( + () => new BigNumber(publicPool?.vestingInformation?.percentage ?? 0).times(0.01), + [publicPool], + ) + + const releasedAtSaleEnd = useMemo(() => { + return new BigNumber(userPool?.offeringAmountInToken ?? 0).times(new BigNumber(1).minus(vestingPercentage)) + }, [userPool, vestingPercentage]) + + const amountReleased = useMemo(() => { + return isVestingOver + ? new BigNumber(userPool?.offeringAmountInToken ?? 0) + : new BigNumber(releasedAtSaleEnd) + .plus(userPool?.vestingReleased ?? 0) + .plus(userPool?.vestingComputeReleasableAmount ?? 0) + }, [isVestingOver, userPool, releasedAtSaleEnd]) + + const amountInVesting = useMemo(() => { + return isVestingOver ? BIG_ZERO : new BigNumber(userPool?.offeringAmountInToken ?? 0).minus(amountReleased) + }, [userPool, amountReleased, isVestingOver]) + + const amountAvailableToClaim = useMemo(() => { + return userPool?.isVestingInitialized ? userPool.vestingComputeReleasableAmount : releasedAtSaleEnd + }, [userPool, releasedAtSaleEnd]) + + const amountAlreadyClaimed = useMemo(() => { + const released = userPool?.isVestingInitialized ? releasedAtSaleEnd : BIG_ZERO + return new BigNumber(released).plus(userPool?.vestingReleased ?? 0) + }, [releasedAtSaleEnd, userPool]) + + return { + isVestingOver, + vestingPercentage, + releasedAtSaleEnd, + amountReleased, + amountInVesting, + amountAvailableToClaim, + amountAlreadyClaimed, + } +} + +export default useIfoVesting diff --git a/apps/web/src/views/Idos/hooks/useUserIfoInfo.ts b/apps/web/src/views/Idos/hooks/useUserIfoInfo.ts new file mode 100644 index 0000000000000..66f9e7ec218e2 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/useUserIfoInfo.ts @@ -0,0 +1,81 @@ +import { getCurrentIfoRatio, getUserIfoInfo } from '@pancakeswap/ifos' +import { ChainId, CurrencyAmount } from '@pancakeswap/sdk' +import { CAKE } from '@pancakeswap/tokens' +import { useQuery } from '@tanstack/react-query' +import BigNumber from 'bignumber.js' +import { useMemo } from 'react' +import { Address } from 'viem' +import { useAccount } from 'wagmi' + +import { getViemClients } from 'utils/viem' + +type ICakeRatioParams = { + chainId?: ChainId +} + +export function useICakeRatio({ chainId }: ICakeRatioParams) { + const { data } = useQuery({ + queryKey: [chainId, 'current-ifo-ratio'], + + queryFn: () => + getCurrentIfoRatio({ + chainId, + provider: getViemClients, + }), + + enabled: Boolean(chainId), + }) + + return data +} + +type Params = { + chainId?: ChainId + ifoAddress?: Address +} + +export function useUserIfoInfo({ chainId, ifoAddress }: Params) { + const { address: account } = useAccount() + const ratio = useICakeRatio({ chainId }) + + const { data } = useQuery({ + queryKey: [account, chainId, ifoAddress, 'user-ifo-info'], + + queryFn: () => + getUserIfoInfo({ + account, + chainId, + ifo: ifoAddress, + provider: getViemClients, + }), + + enabled: Boolean(account && chainId), + }) + + const snapshotTime = useMemo(() => { + const now = Math.floor(Date.now() / 1000) + return data?.endTimestamp && data.endTimestamp > now ? data.endTimestamp : undefined + }, [data?.endTimestamp]) + + const credit = useMemo( + () => + chainId && CAKE[chainId] && data?.credit !== undefined + ? CurrencyAmount.fromRawAmount(CAKE[chainId], data?.credit) + : undefined, + [data?.credit, chainId], + ) + const veCake = useMemo( + () => + credit && ratio + ? new BigNumber(credit.numerator.toString()).div(credit.decimalScale.toString()).div(ratio) + : undefined, + [credit, ratio], + ) + + return { + snapshotTime, + credit, + veCake, + ratio, + } +} diff --git a/apps/web/src/views/Idos/hooks/v1/useGetPublicIfoData.ts b/apps/web/src/views/Idos/hooks/v1/useGetPublicIfoData.ts new file mode 100644 index 0000000000000..0748ea9fd5a86 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v1/useGetPublicIfoData.ts @@ -0,0 +1,99 @@ +import { useState, useCallback } from 'react' +import BigNumber from 'bignumber.js' +import { BSC_BLOCK_TIME } from 'config' +import { Ifo, IfoStatus, PoolIds } from '@pancakeswap/ifos' +import { useLpTokenPrice } from 'state/farms/hooks' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { ifoV1ABI } from 'config/abi/ifoV1' +import { PublicIfoData } from '../../types' +import { getStatus } from '../helpers' + +/** + * Gets all public data of an IFO + */ +const useGetPublicIfoData = (ifo: Ifo): PublicIfoData => { + const { address } = ifo + const lpTokenPriceInUsd = useLpTokenPrice(ifo.currency.symbol) + const [state, setState] = useState({ + isInitialized: false, + status: 'idle' as IfoStatus, + blocksRemaining: 0, + secondsUntilStart: 0, + progress: 5, + secondsUntilEnd: 0, + startBlockNum: 0, + endBlockNum: 0, + numberPoints: null, + thresholdPoints: undefined, + [PoolIds.poolUnlimited]: { + raisingAmountPool: BIG_ZERO, + totalAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, // Not know + limitPerUserInLP: BIG_ZERO, // Not used + taxRate: 0, // Not used + sumTaxesOverflow: BIG_ZERO, // Not used + }, + }) + const fetchIfoData = useCallback( + async (currentBlock: number) => { + const ifoCalls = (['startBlock', 'endBlock', 'raisingAmount', 'totalAmount'] as const).map( + (method) => + ({ + abi: ifoV1ABI, + address, + functionName: method, + } as const), + ) + + const client = publicClient({ chainId: ChainId.BSC }) + + const [startBlockResult, endBlockResult, raisingAmountResult, totalAmountResult] = await client.multicall({ + contracts: ifoCalls, + }) + + const [startBlock, endBlock, raisingAmount, totalAmount] = [ + startBlockResult.result, + endBlockResult.result, + raisingAmountResult.result, + totalAmountResult.result, + ] + + const startBlockNum = startBlock ? Number(startBlock) : 0 + const endBlockNum = endBlock ? Number(endBlock) : 0 + + const status = getStatus(currentBlock, startBlockNum, endBlockNum) + const totalBlocks = endBlockNum - startBlockNum + const blocksRemaining = endBlockNum - currentBlock + + // Calculate the total progress until finished or until start + const progress = status === 'live' ? ((currentBlock - startBlockNum) / totalBlocks) * 100 : null + + setState( + (prev) => + ({ + ...prev, + isInitialized: true, + status, + blocksRemaining, + secondsUntilStart: (startBlockNum - currentBlock) * BSC_BLOCK_TIME, + progress, + secondsUntilEnd: blocksRemaining * BSC_BLOCK_TIME, + startBlockNum, + endBlockNum, + [PoolIds.poolUnlimited]: { + ...prev.poolUnlimited, + raisingAmountPool: raisingAmount ? new BigNumber(raisingAmount.toString()) : BIG_ZERO, + totalAmountPool: totalAmount ? new BigNumber(totalAmount.toString()) : BIG_ZERO, + }, + } as any), + ) + }, + [address], + ) + + return { ...state, currencyPriceInUSD: lpTokenPriceInUsd, fetchIfoData } as any +} + +export default useGetPublicIfoData diff --git a/apps/web/src/views/Idos/hooks/v1/useGetWalletIfoData.ts b/apps/web/src/views/Idos/hooks/v1/useGetWalletIfoData.ts new file mode 100644 index 0000000000000..153d7c509e260 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v1/useGetWalletIfoData.ts @@ -0,0 +1,118 @@ +import { useState, useCallback } from 'react' +import { useAccount } from 'wagmi' +import BigNumber from 'bignumber.js' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useERC20, useIfoV1Contract } from 'hooks/useContract' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { ifoV1ABI } from 'config/abi/ifoV1' +import useIfoAllowance from '../useIfoAllowance' +import { WalletIfoState, WalletIfoData } from '../../types' + +interface UserInfo { + amount: BigNumber + claimed: boolean +} + +const initialState = { + isInitialized: false, + [PoolIds.poolUnlimited]: { + amountTokenCommittedInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, // Not used + }, +} + +/** + * Gets all data from an IFO related to a wallet + */ +const useGetWalletIfoData = (ifo: Ifo): WalletIfoData => { + const [state, setState] = useState(initialState) + + const { address, currency } = ifo + const { poolUnlimited } = state + + const { address: account } = useAccount() + const contract = useIfoV1Contract(address) + const currencyContract = useERC20(currency.address) + const allowance = useIfoAllowance(currencyContract, address, poolUnlimited.isPendingTx) + + const setPendingTx = (status: boolean) => + setState((prevState) => ({ + ...prevState, + [PoolIds.poolUnlimited]: { + ...prevState.poolUnlimited, + isPendingTx: status, + }, + })) + + const setIsClaimed = () => { + setState((prevState) => ({ + ...prevState, + [PoolIds.poolUnlimited]: { + ...prevState.poolUnlimited, + hasClaimed: true, + }, + })) + } + + const fetchIfoData = useCallback(async () => { + if (!account) { + return + } + + const [offeringAmount, userInfoResponse, refundingAmount] = await publicClient({ chainId: ChainId.BSC }).multicall({ + contracts: [ + { + address, + abi: ifoV1ABI, + functionName: 'getOfferingAmount', + args: [account], + }, + { + address, + abi: ifoV1ABI, + functionName: 'userInfo', + args: [account], + }, + { + address, + abi: ifoV1ABI, + functionName: 'getRefundingAmount', + args: [account], + }, + ], + allowFailure: false, + }) + + const parsedUserInfo: UserInfo = userInfoResponse + ? { + amount: new BigNumber(userInfoResponse[0].toString()), + claimed: userInfoResponse[1], + } + : { amount: BIG_ZERO, claimed: false } + + setState((prevState) => ({ + isInitialized: true, + [PoolIds.poolUnlimited]: { + ...prevState.poolUnlimited, + amountTokenCommittedInLP: parsedUserInfo.amount, + hasClaimed: parsedUserInfo.claimed, + offeringAmountInToken: offeringAmount ? new BigNumber(offeringAmount.toString()) : BIG_ZERO, + refundingAmountInLP: refundingAmount ? new BigNumber(refundingAmount.toString()) : BIG_ZERO, + }, + })) + }, [account, address]) + + const resetIfoData = useCallback(() => { + setState(initialState) + }, []) + + return { ...state, allowance, contract, setPendingTx, setIsClaimed, fetchIfoData, resetIfoData, version: 1 } +} + +export default useGetWalletIfoData diff --git a/apps/web/src/views/Idos/hooks/v2/useGetPublicIfoData.ts b/apps/web/src/views/Idos/hooks/v2/useGetPublicIfoData.ts new file mode 100644 index 0000000000000..4f8e116299aeb --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v2/useGetPublicIfoData.ts @@ -0,0 +1,157 @@ +import BigNumber from 'bignumber.js' +import { useState, useCallback } from 'react' +import { BSC_BLOCK_TIME } from 'config' +import { ifoV2ABI } from 'config/abi/ifoV2' +import { bscTokens } from '@pancakeswap/tokens' +import { Ifo, IfoStatus } from '@pancakeswap/ifos' + +import { useLpTokenPrice } from 'state/farms/hooks' +import { useCakePrice } from 'hooks/useCakePrice' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { PublicIfoData } from '../../types' +import { getStatus } from '../helpers' + +// https://github.com/pancakeswap/pancake-contracts/blob/master/projects/ifo/contracts/IFOV2.sol#L431 +// 1,000,000,000 / 100 +const TAX_PRECISION = new BigNumber(10000000000) + +const formatPool = (pool) => ({ + raisingAmountPool: pool ? new BigNumber(pool[0].toString()) : BIG_ZERO, + offeringAmountPool: pool ? new BigNumber(pool[1].toString()) : BIG_ZERO, + limitPerUserInLP: pool ? new BigNumber(pool[2].toString()) : BIG_ZERO, + hasTax: pool ? pool[3] : false, + totalAmountPool: pool ? new BigNumber(pool[4].toString()) : BIG_ZERO, + sumTaxesOverflow: pool ? new BigNumber(pool[5].toString()) : BIG_ZERO, +}) + +/** + * Gets all public data of an IFO + */ +const useGetPublicIfoData = (ifo: Ifo): PublicIfoData => { + const { address } = ifo + const cakePrice = useCakePrice() + const lpTokenPriceInUsd = useLpTokenPrice(ifo.currency.symbol) + const currencyPriceInUSD = ifo.currency === bscTokens.cake ? cakePrice : lpTokenPriceInUsd + + const [state, setState] = useState({ + isInitialized: false, + status: 'idle' as IfoStatus, + blocksRemaining: 0, + secondsUntilStart: 0, + progress: 5, + secondsUntilEnd: 0, + poolBasic: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + }, + poolUnlimited: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + }, + thresholdPoints: undefined, + startBlockNum: 0, + endBlockNum: 0, + numberPoints: 0, + }) + + const fetchIfoData = useCallback( + async (currentBlock: number) => { + const client = publicClient({ chainId: ChainId.BSC }) + const [startBlock, endBlock, poolBasic, poolUnlimited, taxRate, numberPoints, thresholdPoints] = + await client.multicall({ + contracts: [ + { + abi: ifoV2ABI, + address, + functionName: 'startBlock', + }, + { + abi: ifoV2ABI, + address, + functionName: 'endBlock', + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolInformation', + args: [0n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolInformation', + args: [1n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [1n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'numberPoints', + }, + { + abi: ifoV2ABI, + address, + functionName: 'thresholdPoints', + }, + ], + allowFailure: false, + }) + + const poolBasicFormatted = formatPool(poolBasic) + const poolUnlimitedFormatted = formatPool(poolUnlimited) + + const startBlockNum = startBlock ? Number(startBlock) : 0 + const endBlockNum = endBlock ? Number(endBlock) : 0 + const taxRateNum = taxRate ? new BigNumber(taxRate.toString()).div(TAX_PRECISION).toNumber() : 0 + + const status = getStatus(currentBlock, startBlockNum, endBlockNum) + const totalBlocks = endBlockNum - startBlockNum + const blocksRemaining = endBlockNum - currentBlock + + // Calculate the total progress until finished or until start + const progress = status === 'live' ? ((currentBlock - startBlockNum) / totalBlocks) * 100 : null + + setState( + (prev) => + ({ + ...prev, + isInitialized: true, + secondsUntilEnd: blocksRemaining * BSC_BLOCK_TIME, + secondsUntilStart: (startBlockNum - currentBlock) * BSC_BLOCK_TIME, + poolBasic: { + ...poolBasicFormatted, + taxRate: 0, + }, + poolUnlimited: { ...poolUnlimitedFormatted, taxRate: taxRateNum }, + status, + progress, + blocksRemaining, + startBlockNum, + endBlockNum, + thresholdPoints, + numberPoints: numberPoints ? Number(numberPoints) : 0, + } as any), + ) + }, + [address], + ) + + return { ...state, currencyPriceInUSD, fetchIfoData } as any +} + +export default useGetPublicIfoData diff --git a/apps/web/src/views/Idos/hooks/v2/useGetWalletIfoData.ts b/apps/web/src/views/Idos/hooks/v2/useGetWalletIfoData.ts new file mode 100644 index 0000000000000..061b94862fbee --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v2/useGetWalletIfoData.ts @@ -0,0 +1,121 @@ +import { useState, useCallback } from 'react' +import { useAccount } from 'wagmi' +import BigNumber from 'bignumber.js' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useERC20, useIfoV2Contract } from 'hooks/useContract' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { ifoV2ABI } from 'config/abi/ifoV2' +import useIfoAllowance from '../useIfoAllowance' +import { WalletIfoState, WalletIfoData } from '../../types' + +const initialState = { + isInitialized: false, + poolBasic: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + }, + poolUnlimited: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + }, +} + +/** + * Gets all data from an IFO related to a wallet + */ +const useGetWalletIfoData = (ifo: Ifo): WalletIfoData => { + const [state, setState] = useState(initialState) + + const { address, currency } = ifo + + const { address: account } = useAccount() + const contract = useIfoV2Contract(address) + const currencyContract = useERC20(currency.address) + const allowance = useIfoAllowance(currencyContract, address) + + const setPendingTx = (status: boolean, poolId: PoolIds) => + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + isPendingTx: status, + }, + })) + + const setIsClaimed = (poolId: PoolIds) => { + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + hasClaimed: true, + }, + })) + } + + const fetchIfoData = useCallback(async () => { + const bscClient = publicClient({ chainId: ChainId.BSC }) + + if (!account) { + return + } + const [userInfo, amounts] = await bscClient.multicall({ + contracts: [ + { + address, + abi: ifoV2ABI, + functionName: 'viewUserInfo', + args: [account, [0, 1]], + }, + { + address, + abi: ifoV2ABI, + functionName: 'viewUserOfferingAndRefundingAmountsForPools', + args: [account, [0, 1]], + }, + ], + allowFailure: false, + }) + + setState( + (prevState) => + ({ + ...prevState, + isInitialized: true, + poolBasic: { + ...prevState.poolBasic, + amountTokenCommittedInLP: new BigNumber(userInfo[0][0].toString()), + offeringAmountInToken: new BigNumber(amounts[0][0].toString()), + refundingAmountInLP: new BigNumber(amounts[0][1].toString()), + taxAmountInLP: new BigNumber(amounts[0][2].toString()), + hasClaimed: userInfo[1][0], + }, + poolUnlimited: { + ...prevState.poolUnlimited, + amountTokenCommittedInLP: new BigNumber(userInfo[0][1].toString()), + offeringAmountInToken: new BigNumber(amounts[1][0].toString()), + refundingAmountInLP: new BigNumber(amounts[1][1].toString()), + taxAmountInLP: new BigNumber(amounts[1][2].toString()), + hasClaimed: userInfo[1][1], + }, + } as any), + ) + }, [account, address]) + + const resetIfoData = useCallback(() => { + setState({ ...initialState }) + }, []) + + return { ...state, allowance, contract, setPendingTx, setIsClaimed, fetchIfoData, resetIfoData, version: 2 } +} + +export default useGetWalletIfoData diff --git a/apps/web/src/views/Idos/hooks/v3/useCriterias.ts b/apps/web/src/views/Idos/hooks/v3/useCriterias.ts new file mode 100644 index 0000000000000..07e7d1019c40b --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v3/useCriterias.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react' + +const mapCriteriasToQualifications = { + needQualifiedNFT: 'isQualifiedNFT', + needQualifiedPoints: 'isQualifiedPoints', +} + +export default function useCriterias(userBasicPoolInfo, ifoCriterias) { + const criterias = useMemo( + () => + Object.keys(ifoCriterias) + .filter((key) => ifoCriterias[key]) + .map((key) => ({ + type: mapCriteriasToQualifications[key], + value: Boolean(userBasicPoolInfo[mapCriteriasToQualifications[key]]), + })), + [ifoCriterias, userBasicPoolInfo], + ) + + const isEligible = useMemo(() => criterias.length === 0 || criterias.some((criteria) => criteria?.value), [criterias]) + + return { + isEligible, + criterias, + } +} diff --git a/apps/web/src/views/Idos/hooks/v3/useGetPublicIfoData.ts b/apps/web/src/views/Idos/hooks/v3/useGetPublicIfoData.ts new file mode 100644 index 0000000000000..08e6b9993e537 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v3/useGetPublicIfoData.ts @@ -0,0 +1,262 @@ +import BigNumber from 'bignumber.js' +import { useState, useCallback } from 'react' +import { BSC_BLOCK_TIME } from 'config' +import round from 'lodash/round' +import { ifoV2ABI } from 'config/abi/ifoV2' +import { ifoV3ABI } from 'config/abi/ifoV3' +import { bscTokens } from '@pancakeswap/tokens' +import { Ifo, IfoStatus } from '@pancakeswap/ifos' + +import { useLpTokenPrice } from 'state/farms/hooks' +import { useCakePrice } from 'hooks/useCakePrice' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { PublicIfoData } from '../../types' +import { getStatus } from '../helpers' + +// https://github.com/pancakeswap/pancake-contracts/blob/master/projects/ifo/contracts/IFOV2.sol#L431 +// 1,000,000,000 / 100 +const TAX_PRECISION = new BigNumber(10000000000) + +const NO_QUALIFIED_NFT_ADDRESS = '0x0000000000000000000000000000000000000000' + +const formatPool = (pool) => ({ + raisingAmountPool: pool ? new BigNumber(pool[0].toString()) : BIG_ZERO, + offeringAmountPool: pool ? new BigNumber(pool[1].toString()) : BIG_ZERO, + limitPerUserInLP: pool ? new BigNumber(pool[2].toString()) : BIG_ZERO, + hasTax: pool ? pool[3] : false, + totalAmountPool: pool ? new BigNumber(pool[4].toString()) : BIG_ZERO, + sumTaxesOverflow: pool ? new BigNumber(pool[5].toString()) : BIG_ZERO, +}) + +const formatVestingInfo = (pool) => ({ + percentage: pool ? Number(pool[0]) : 0, + cliff: pool ? Number(pool[1]) : 0, + duration: pool ? Number(pool[2]) : 0, + slicePeriodSeconds: pool ? Number(pool[3]) : 0, +}) + +const ROUND_DIGIT = 3 + +/** + * Gets all public data of an IFO + */ +const useGetPublicIfoData = (ifo: Ifo): PublicIfoData => { + const { address, plannedStartTime } = ifo + const cakePrice = useCakePrice() + const lpTokenPriceInUsd = useLpTokenPrice(ifo.currency.symbol) + const currencyPriceInUSD = ifo.currency === bscTokens.cake ? cakePrice : lpTokenPriceInUsd + + const [state, setState] = useState({ + isInitialized: false, + status: 'idle' as IfoStatus, + blocksRemaining: 0, + secondsUntilStart: 0, + progress: 5, + secondsUntilEnd: 0, + poolBasic: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + pointThreshold: 0, + admissionProfile: undefined, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + }, + poolUnlimited: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + }, + thresholdPoints: undefined, + startBlockNum: 0, + endBlockNum: 0, + numberPoints: 0, + vestingStartTime: 0, + plannedStartTime: 0, + }) + + const fetchIfoData = useCallback( + async (currentBlock: number) => { + const client = publicClient({ chainId: ChainId.BSC }) + const [ + startBlock, + endBlock, + poolBasic, + poolUnlimited, + taxRate, + numberPoints, + thresholdPoints, + privateSaleTaxRate, + ] = await client.multicall({ + contracts: [ + { + abi: ifoV2ABI, + address, + functionName: 'startBlock', + }, + { + abi: ifoV2ABI, + address, + functionName: 'endBlock', + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolInformation', + args: [0n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolInformation', + args: [1n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [1n], + }, + { + abi: ifoV2ABI, + address, + functionName: 'numberPoints', + }, + { + abi: ifoV2ABI, + address, + functionName: 'thresholdPoints', + }, + { + abi: ifoV2ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [0n], + }, + ], + allowFailure: false, + }) + + const [admissionProfile, pointThreshold, vestingStartTime, basicVestingInformation, unlimitedVestingInformation] = + await client.multicall({ + contracts: [ + { + abi: ifoV3ABI, + address, + functionName: 'admissionProfile', + }, + { + abi: ifoV3ABI, + address, + functionName: 'pointThreshold', + }, + { + abi: ifoV3ABI, + address, + functionName: 'vestingStartTime', + }, + { + abi: ifoV3ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [0n], + }, + { + abi: ifoV3ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [1n], + }, + ], + allowFailure: true, + }) + + const poolBasicFormatted = formatPool(poolBasic) + const poolUnlimitedFormatted = formatPool(poolUnlimited) + + const startBlockNum = startBlock ? Number(startBlock) : 0 + const endBlockNum = endBlock ? Number(endBlock) : 0 + const taxRateNum = taxRate ? new BigNumber(taxRate.toString()).div(TAX_PRECISION).toNumber() : 0 + const privateSaleTaxRateNum = privateSaleTaxRate + ? new BigNumber(privateSaleTaxRate.toString()).div(TAX_PRECISION).toNumber() + : 0 + + const status = getStatus(currentBlock, startBlockNum, endBlockNum) + const totalBlocks = endBlockNum - startBlockNum + const blocksRemaining = endBlockNum - currentBlock + + // Calculate the total progress until finished or until start + const progress = status === 'live' ? ((currentBlock - startBlockNum) / totalBlocks) * 100 : null + + const totalOfferingAmount = poolBasicFormatted.offeringAmountPool.plus(poolUnlimitedFormatted.offeringAmountPool) + + setState( + (prev) => + ({ + ...prev, + isInitialized: true, + secondsUntilEnd: blocksRemaining * BSC_BLOCK_TIME, + secondsUntilStart: (startBlockNum - currentBlock) * BSC_BLOCK_TIME, + poolBasic: { + ...poolBasicFormatted, + taxRate: privateSaleTaxRateNum, + distributionRatio: round( + poolBasicFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + pointThreshold: pointThreshold.result ? Number(pointThreshold.result) : 0, + admissionProfile: + Boolean(admissionProfile && admissionProfile.result) && + admissionProfile.result !== NO_QUALIFIED_NFT_ADDRESS + ? admissionProfile.result + : undefined, + vestingInformation: formatVestingInfo(basicVestingInformation.result), + }, + poolUnlimited: { + ...poolUnlimitedFormatted, + taxRate: taxRateNum, + distributionRatio: round( + poolUnlimitedFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + vestingInformation: formatVestingInfo(unlimitedVestingInformation.result), + }, + status, + progress, + blocksRemaining, + startBlockNum, + endBlockNum, + thresholdPoints, + numberPoints: numberPoints ? Number(numberPoints) : 0, + plannedStartTime: plannedStartTime ?? 0, + vestingStartTime: vestingStartTime.result ? Number(vestingStartTime.result) : 0, + } as any), + ) + }, + [plannedStartTime, address], + ) + + return { ...state, currencyPriceInUSD, fetchIfoData } as any +} + +export default useGetPublicIfoData diff --git a/apps/web/src/views/Idos/hooks/v3/useGetWalletIfoData.ts b/apps/web/src/views/Idos/hooks/v3/useGetWalletIfoData.ts new file mode 100644 index 0000000000000..f219a83826402 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v3/useGetWalletIfoData.ts @@ -0,0 +1,272 @@ +import { useState, useCallback } from 'react' +import { useAccount } from 'wagmi' +import BigNumber from 'bignumber.js' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import { useERC20, useIfoV3Contract } from 'hooks/useContract' +import { fetchCakeVaultUserData } from 'state/pools' +import { useAppDispatch } from 'state' +import { useIfoCredit } from 'state/pools/hooks' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { publicClient } from 'utils/wagmi' +import { ChainId } from '@pancakeswap/chains' +import { ifoV3ABI } from 'config/abi/ifoV3' + +import useIfoAllowance from '../useIfoAllowance' +import { WalletIfoState, WalletIfoData } from '../../types' + +const initialState = { + isInitialized: false, + poolBasic: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, + poolUnlimited: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, +} + +/** + * Gets all data from an IFO related to a wallet + */ +const useGetWalletIfoData = (ifo: Ifo): WalletIfoData => { + const [state, setState] = useState(initialState) + const dispatch = useAppDispatch() + const credit = useIfoCredit() + const { chainId } = useActiveChainId() + + const { address, currency, version } = ifo + + const { address: account } = useAccount() + const contract = useIfoV3Contract(address) + const currencyContract = useERC20(currency.address) + const allowance = useIfoAllowance(currencyContract, address) + + const setPendingTx = (status: boolean, poolId: PoolIds) => + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + isPendingTx: status, + }, + })) + + const setIsClaimed = (poolId: PoolIds) => { + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + hasClaimed: true, + }, + })) + } + + const fetchIfoData = useCallback(async () => { + const bscClient = publicClient({ chainId: ChainId.BSC }) + + if (!account) { + return + } + + const [userInfo, amounts] = await bscClient.multicall({ + contracts: [ + { + address, + abi: ifoV3ABI, + functionName: 'viewUserInfo', + args: [account, [0, 1]], + }, + { + address, + abi: ifoV3ABI, + functionName: 'viewUserOfferingAndRefundingAmountsForPools', + args: [account, [0, 1]], + }, + ], + allowFailure: false, + }) + + let basicId = null + let unlimitedId = null + if (version >= 3.2) { + const [basicIdDataResult, unlimitedIdDataResult] = await bscClient.multicall({ + contracts: [ + { + address, + abi: ifoV3ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 0n], + }, + { + address, + abi: ifoV3ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 1n], + }, + ], + }) + + basicId = basicIdDataResult.result as any + unlimitedId = unlimitedIdDataResult.result as any + } + + let [ + isQualifiedNFT, + isQualifiedPoints, + basicSchedule, + unlimitedSchedule, + basicReleasableAmount, + unlimitedReleasableAmount, + ] = [false, false, null, null, null, null] + + if (version >= 3.1) { + const [ + isQualifiedNFTResult, + isQualifiedPointsResult, + basicScheduleResult, + unlimitedScheduleResult, + basicReleasableAmountResult, + unlimitedReleasableAmountResult, + ] = await bscClient.multicall({ + contracts: [ + { + address, + abi: ifoV3ABI, + functionName: 'isQualifiedNFT', + args: [account], + }, + { + abi: ifoV3ABI, + address, + functionName: 'isQualifiedPoints', + args: [account], + }, + { + abi: ifoV3ABI, + address, + functionName: 'getVestingSchedule', + args: [basicId as any], + }, + { + abi: ifoV3ABI, + address, + functionName: 'getVestingSchedule', + args: [unlimitedId as any], + }, + { + abi: ifoV3ABI, + address, + functionName: 'computeReleasableAmount', + args: [basicId as any], + }, + { + abi: ifoV3ABI, + address, + functionName: 'computeReleasableAmount', + args: [unlimitedId as any], + }, + ], + allowFailure: true, + }) + + isQualifiedNFT = isQualifiedNFTResult.result as any + isQualifiedPoints = isQualifiedPointsResult.result as any + basicSchedule = basicScheduleResult.result as any + unlimitedSchedule = unlimitedScheduleResult.result as any + basicReleasableAmount = basicReleasableAmountResult.result as any + unlimitedReleasableAmount = unlimitedReleasableAmountResult.result as any + } + + if (chainId) { + dispatch(fetchCakeVaultUserData({ account, chainId })) + } + + setState( + (prevState) => + ({ + ...prevState, + isInitialized: true, + poolBasic: { + ...prevState.poolBasic, + amountTokenCommittedInLP: new BigNumber(userInfo[0][0].toString()), + offeringAmountInToken: new BigNumber(amounts[0][0].toString()), + refundingAmountInLP: new BigNumber(amounts[0][1].toString()), + taxAmountInLP: new BigNumber(amounts[0][2].toString()), + hasClaimed: userInfo[1][0], + isQualifiedNFT, + isQualifiedPoints, + vestingReleased: basicSchedule ? new BigNumber((basicSchedule as any).released.toString()) : BIG_ZERO, + vestingAmountTotal: basicSchedule ? new BigNumber((basicSchedule as any).amountTotal.toString()) : BIG_ZERO, + isVestingInitialized: basicSchedule ? (basicSchedule as any).isVestingInitialized : false, + vestingId: basicId ? (basicId as any).toString() : '0', + vestingComputeReleasableAmount: basicReleasableAmount + ? new BigNumber((basicReleasableAmount as any).toString()) + : BIG_ZERO, + }, + poolUnlimited: { + ...prevState.poolUnlimited, + amountTokenCommittedInLP: new BigNumber(userInfo[0][1].toString()), + offeringAmountInToken: new BigNumber(amounts[1][0].toString()), + refundingAmountInLP: new BigNumber(amounts[1][1].toString()), + taxAmountInLP: new BigNumber(amounts[1][2].toString()), + hasClaimed: userInfo[1][1], + vestingReleased: unlimitedSchedule + ? new BigNumber((unlimitedSchedule as any).released.toString()) + : BIG_ZERO, + vestingAmountTotal: unlimitedSchedule + ? new BigNumber((unlimitedSchedule as any).amountTotal.toString()) + : BIG_ZERO, + isVestingInitialized: unlimitedSchedule ? (unlimitedSchedule as any).isVestingInitialized : false, + vestingId: unlimitedId ? (unlimitedId as any).toString() : '0', + vestingComputeReleasableAmount: unlimitedReleasableAmount + ? new BigNumber((unlimitedReleasableAmount as any).toString()) + : BIG_ZERO, + }, + } as any), + ) + }, [account, address, dispatch, version, chainId]) + + const resetIfoData = useCallback(() => { + setState({ ...initialState }) + }, []) + + const creditLeftWithNegative = credit.minus(state.poolUnlimited.amountTokenCommittedInLP) + + const ifoCredit = { + credit, + creditLeft: BigNumber.maximum(BIG_ZERO, creditLeftWithNegative), + } + + return { + ...state, + allowance, + contract, + setPendingTx, + setIsClaimed, + fetchIfoData, + resetIfoData, + ifoCredit, + version: 3, + } +} + +export default useGetWalletIfoData diff --git a/apps/web/src/views/Idos/hooks/v7/helpers.ts b/apps/web/src/views/Idos/hooks/v7/helpers.ts new file mode 100644 index 0000000000000..952b1053e0279 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v7/helpers.ts @@ -0,0 +1,3 @@ +export function isBasicSale(saleType?: number) { + return saleType === 2 +} diff --git a/apps/web/src/views/Idos/hooks/v7/useGetPublicIfoData.ts b/apps/web/src/views/Idos/hooks/v7/useGetPublicIfoData.ts new file mode 100644 index 0000000000000..143b7801673a2 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v7/useGetPublicIfoData.ts @@ -0,0 +1,274 @@ +import BigNumber from 'bignumber.js' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { useState, useCallback, useEffect } from 'react' +import round from 'lodash/round' +import { CAKE } from '@pancakeswap/tokens' +import { Ifo, IfoStatus, ifoV7ABI } from '@pancakeswap/ifos' +import { useAccount } from 'wagmi' + +import { useLpTokenPrice } from 'state/farms/hooks' +import { useCakePrice } from 'hooks/useCakePrice' +import { publicClient } from 'utils/wagmi' +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { PublicIfoData } from '../../types' +import { getStatusByTimestamp } from '../helpers' + +// https://github.com/pancakeswap/pancake-contracts/blob/master/projects/ifo/contracts/IFOV2.sol#L431 +// 1,000,000,000 / 100 +const TAX_PRECISION = new BigNumber(10000000000) + +const NO_QUALIFIED_NFT_ADDRESS = '0x0000000000000000000000000000000000000000' + +const formatPool = (pool: readonly [bigint, bigint, bigint, boolean, bigint, bigint, number]) => ({ + raisingAmountPool: pool ? new BigNumber(pool[0].toString()) : BIG_ZERO, + offeringAmountPool: pool ? new BigNumber(pool[1].toString()) : BIG_ZERO, + limitPerUserInLP: pool ? new BigNumber(pool[2].toString()) : BIG_ZERO, + hasTax: pool ? pool[3] : false, + totalAmountPool: pool ? new BigNumber(pool[4].toString()) : BIG_ZERO, + sumTaxesOverflow: pool ? new BigNumber(pool[5].toString()) : BIG_ZERO, + saleType: pool ? pool[6] : 0, +}) + +const formatVestingInfo = (pool: readonly [bigint, bigint, bigint, bigint]) => ({ + percentage: pool ? Number(pool[0]) : 0, + cliff: pool ? Number(pool[1]) : 0, + duration: pool ? Number(pool[2]) : 0, + slicePeriodSeconds: pool ? Number(pool[3]) : 0, +}) + +const ROUND_DIGIT = 3 + +const INITIAL_STATE = { + isInitialized: false, + status: 'idle' as IfoStatus, + secondsUntilStart: 0, + progress: 5, + secondsUntilEnd: 0, + poolBasic: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + pointThreshold: 0, + admissionProfile: undefined, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + + // 0: public sale + // 1: private sale + // 2: basic sale + saleType: undefined, + }, + poolUnlimited: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + saleType: undefined, + }, + thresholdPoints: undefined, + startTimestamp: 0, + endTimestamp: 0, + numberPoints: 0, + vestingStartTime: 0, + plannedStartTime: 0, +} + +/** + * Gets all public data of an IFO + */ +const useGetPublicIfoData = (ifo: Ifo): PublicIfoData => { + const { chainId: currentChainId } = useActiveChainId() + const { address: account } = useAccount() + const { chainId } = ifo + const { address, plannedStartTime } = ifo + const cakePrice = useCakePrice() + const lpTokenPriceInUsd = useLpTokenPrice(ifo.currency.symbol) + const currencyPriceInUSD = ifo.currency === CAKE[ifo.chainId] ? cakePrice : lpTokenPriceInUsd + + const [state, setState] = useState(INITIAL_STATE) + + const fetchIfoData = useCallback(async () => { + const client = publicClient({ chainId }) + const [ + [ + startTimestamp, + endTimestamp, + poolBasic, + poolUnlimited, + taxRate, + numberPoints, + thresholdPoints, + privateSaleTaxRate, + ], + [admissionProfile, pointThreshold, vestingStartTime, basicVestingInformation, unlimitedVestingInformation], + ] = await Promise.all([ + client.multicall({ + contracts: [ + { + abi: ifoV7ABI, + address, + functionName: 'startTimestamp', + }, + { + abi: ifoV7ABI, + address, + functionName: 'endTimestamp', + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolInformation', + args: [0n], + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolInformation', + args: [1n], + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [1n], + }, + { + abi: ifoV7ABI, + address, + functionName: 'numberPoints', + }, + { + abi: ifoV7ABI, + address, + functionName: 'thresholdPoints', + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [0n], + }, + ], + allowFailure: false, + }), + client.multicall({ + contracts: [ + { + abi: ifoV7ABI, + address, + functionName: 'admissionProfile', + }, + { + abi: ifoV7ABI, + address, + functionName: 'pointThreshold', + }, + { + abi: ifoV7ABI, + address, + functionName: 'vestingStartTime', + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [0n], + }, + { + abi: ifoV7ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [1n], + }, + ], + allowFailure: true, + }), + ]) + + const poolBasicFormatted = formatPool(poolBasic) + const poolUnlimitedFormatted = formatPool(poolUnlimited) + + const startTime = Number(startTimestamp) || 0 + const endTime = Number(endTimestamp) || 0 + const taxRateNum = taxRate ? new BigNumber(taxRate.toString()).div(TAX_PRECISION).toNumber() : 0 + const privateSaleTaxRateNum = privateSaleTaxRate + ? new BigNumber(privateSaleTaxRate.toString()).div(TAX_PRECISION).toNumber() + : 0 + + const now = Math.floor(Date.now() / 1000) + const status = getStatusByTimestamp(now, startTime, endTime) + const duration = endTime - startTime + const secondsUntilEnd = endTime - now + + // Calculate the total progress until finished or until start + const progress = status === 'live' ? ((now - startTime) / duration) * 100 : null + + const totalOfferingAmount = poolBasicFormatted.offeringAmountPool.plus(poolUnlimitedFormatted.offeringAmountPool) + + setState( + (prev) => + ({ + ...prev, + isInitialized: true, + secondsUntilEnd, + secondsUntilStart: startTime - now, + poolBasic: { + ...poolBasicFormatted, + taxRate: privateSaleTaxRateNum, + distributionRatio: round( + poolBasicFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + pointThreshold: pointThreshold.result ? Number(pointThreshold.result) : 0, + admissionProfile: + Boolean(admissionProfile && admissionProfile.result) && + admissionProfile.result !== NO_QUALIFIED_NFT_ADDRESS + ? admissionProfile.result + : undefined, + vestingInformation: formatVestingInfo(basicVestingInformation.result || [0n, 0n, 0n, 0n]), + }, + poolUnlimited: { + ...poolUnlimitedFormatted, + taxRate: taxRateNum, + distributionRatio: round( + poolUnlimitedFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + vestingInformation: formatVestingInfo(unlimitedVestingInformation.result || [0n, 0n, 0n, 0n]), + }, + status, + progress, + startTimestamp: startTime, + endTimestamp: endTime, + thresholdPoints, + numberPoints: numberPoints ? Number(numberPoints) : 0, + plannedStartTime: plannedStartTime ?? 0, + vestingStartTime: vestingStartTime.result ? Number(vestingStartTime.result) : 0, + } as any), + ) + }, [plannedStartTime, address, chainId]) + + useEffect(() => setState(INITIAL_STATE), [currentChainId, account]) + + return { ...state, currencyPriceInUSD, fetchIfoData } as any +} + +export default useGetPublicIfoData diff --git a/apps/web/src/views/Idos/hooks/v7/useGetWalletIfoData.ts b/apps/web/src/views/Idos/hooks/v7/useGetWalletIfoData.ts new file mode 100644 index 0000000000000..4a95fecdd653b --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v7/useGetWalletIfoData.ts @@ -0,0 +1,284 @@ +import { useState, useCallback, useEffect, useMemo } from 'react' +import { useAccount } from 'wagmi' +import { Address } from 'viem' +import BigNumber from 'bignumber.js' +import { Ifo, PoolIds, ifoV7ABI } from '@pancakeswap/ifos' +import { useERC20, useIfoV7Contract } from 'hooks/useContract' +import { fetchCakeVaultUserData } from 'state/pools' +import { useAppDispatch } from 'state' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import { publicClient } from 'utils/wagmi' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import useIfoAllowance from '../useIfoAllowance' +import { WalletIfoState, WalletIfoData } from '../../types' +import { useIfoSourceChain } from '../useIfoSourceChain' +import { useIfoCredit } from '../useIfoCredit' + +const initialState = { + isInitialized: false, + poolBasic: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, + poolUnlimited: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, +} + +/** + * Gets all data from an IFO related to a wallet + */ +const useGetWalletIfoData = (ifo: Ifo): WalletIfoData => { + const { chainId: currenctChainId } = useActiveChainId() + const [state, setState] = useState(initialState) + const dispatch = useAppDispatch() + const { chainId } = ifo + const creditAmount = useIfoCredit({ chainId, ifoAddress: ifo.address }) + const credit = useMemo( + () => (creditAmount && BigNumber(creditAmount.quotient.toString())) ?? BIG_ZERO, + [creditAmount], + ) + const sourceChain = useIfoSourceChain(chainId) + + const { address, currency, version } = ifo + + const { address: account } = useAccount() + const contract = useIfoV7Contract(address, { chainId }) + const currencyContract = useERC20(currency.address, { chainId }) + const allowance = useIfoAllowance(currencyContract, address) + + const setPendingTx = (status: boolean, poolId: PoolIds) => + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + isPendingTx: status, + }, + })) + + const setIsClaimed = (poolId: PoolIds) => { + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + hasClaimed: true, + }, + })) + } + + const fetchIfoData = useCallback(async () => { + if (!account) { + return + } + const client = publicClient({ chainId }) + + const [userInfo, amounts] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV7ABI, + functionName: 'viewUserInfo', + args: [account, [0, 1]], + }, + { + address, + abi: ifoV7ABI, + functionName: 'viewUserOfferingAndRefundingAmountsForPools', + args: [account, [0, 1]], + }, + ], + allowFailure: false, + }) + + let basicId: Address | null = null + let unlimitedId: Address | null = null + if (version >= 3.2) { + const [basicIdDataResult, unlimitedIdDataResult] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV7ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 0n], + }, + { + address, + abi: ifoV7ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 1n], + }, + ], + }) + + basicId = basicIdDataResult.result ?? null + unlimitedId = unlimitedIdDataResult.result ?? null + } + + basicId = basicId || '0x' + unlimitedId = unlimitedId || '0x' + + let [ + isQualifiedNFT, + isQualifiedPoints, + basicSchedule, + unlimitedSchedule, + basicReleasableAmount, + unlimitedReleasableAmount, + ]: [boolean | undefined, boolean | undefined, any | null, any | null, any | null, any | null] = [ + false, + false, + null, + null, + null, + null, + ] + + if (version >= 3.1) { + const [ + isQualifiedNFTResult, + isQualifiedPointsResult, + basicScheduleResult, + unlimitedScheduleResult, + basicReleasableAmountResult, + unlimitedReleasableAmountResult, + ] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV7ABI, + functionName: 'isQualifiedNFT', + args: [account], + }, + { + abi: ifoV7ABI, + address, + functionName: 'isQualifiedPoints', + args: [account], + }, + { + abi: ifoV7ABI, + address, + functionName: 'getVestingSchedule', + args: [basicId], + }, + { + abi: ifoV7ABI, + address, + functionName: 'getVestingSchedule', + args: [unlimitedId], + }, + { + abi: ifoV7ABI, + address, + functionName: 'computeReleasableAmount', + args: [basicId], + }, + { + abi: ifoV7ABI, + address, + functionName: 'computeReleasableAmount', + args: [unlimitedId], + }, + ], + allowFailure: true, + }) + + isQualifiedNFT = isQualifiedNFTResult.result + isQualifiedPoints = isQualifiedPointsResult.result + basicSchedule = basicScheduleResult.result + unlimitedSchedule = unlimitedScheduleResult.result + basicReleasableAmount = basicReleasableAmountResult.result + unlimitedReleasableAmount = unlimitedReleasableAmountResult.result + } + + dispatch(fetchCakeVaultUserData({ account, chainId: sourceChain })) + + setState( + (prevState) => + ({ + ...prevState, + isInitialized: true, + poolBasic: { + ...prevState.poolBasic, + amountTokenCommittedInLP: new BigNumber(userInfo[0][0].toString()), + offeringAmountInToken: new BigNumber(amounts[0][0].toString()), + refundingAmountInLP: new BigNumber(amounts[0][1].toString()), + taxAmountInLP: new BigNumber(amounts[0][2].toString()), + hasClaimed: userInfo[1][0], + isQualifiedNFT, + isQualifiedPoints, + vestingReleased: basicSchedule ? new BigNumber(basicSchedule.released.toString()) : BIG_ZERO, + vestingAmountTotal: basicSchedule ? new BigNumber(basicSchedule.amountTotal.toString()) : BIG_ZERO, + isVestingInitialized: basicSchedule ? basicSchedule.isVestingInitialized : false, + vestingId: basicId ? basicId.toString() : '0', + vestingComputeReleasableAmount: basicReleasableAmount + ? new BigNumber(basicReleasableAmount.toString()) + : BIG_ZERO, + }, + poolUnlimited: { + ...prevState.poolUnlimited, + amountTokenCommittedInLP: new BigNumber(userInfo[0][1].toString()), + offeringAmountInToken: new BigNumber(amounts[1][0].toString()), + refundingAmountInLP: new BigNumber(amounts[1][1].toString()), + taxAmountInLP: new BigNumber(amounts[1][2].toString()), + hasClaimed: userInfo[1][1], + vestingReleased: unlimitedSchedule ? new BigNumber(unlimitedSchedule.released.toString()) : BIG_ZERO, + vestingAmountTotal: unlimitedSchedule ? new BigNumber(unlimitedSchedule.amountTotal.toString()) : BIG_ZERO, + isVestingInitialized: unlimitedSchedule ? unlimitedSchedule.isVestingInitialized : false, + vestingId: unlimitedId ? unlimitedId.toString() : '0', + vestingComputeReleasableAmount: unlimitedReleasableAmount + ? new BigNumber(unlimitedReleasableAmount.toString()) + : BIG_ZERO, + }, + } as any), + ) + }, [account, address, dispatch, version, chainId, sourceChain]) + + const resetIfoData = useCallback(() => { + setState({ ...initialState }) + }, []) + + const creditLeftWithNegative = credit.minus(state.poolUnlimited.amountTokenCommittedInLP) + + const ifoCredit = { + credit, + creditLeft: BigNumber.maximum(BIG_ZERO, creditLeftWithNegative), + } + + useEffect(() => resetIfoData(), [currenctChainId, account, resetIfoData]) + + return { + ...state, + allowance, + contract, + setPendingTx, + setIsClaimed, + fetchIfoData, + resetIfoData, + ifoCredit, + version: 7, + } +} + +export default useGetWalletIfoData diff --git a/apps/web/src/views/Idos/hooks/v8/useGetPublicIfoData.ts b/apps/web/src/views/Idos/hooks/v8/useGetPublicIfoData.ts new file mode 100644 index 0000000000000..720dd559ce407 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v8/useGetPublicIfoData.ts @@ -0,0 +1,262 @@ +import { Ifo, IfoStatus, ifoV8ABI } from '@pancakeswap/ifos' +import { CAKE } from '@pancakeswap/tokens' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import BigNumber from 'bignumber.js' +import round from 'lodash/round' +import { useCallback, useEffect, useState } from 'react' +import { useAccount } from 'wagmi' + +import { useActiveChainId } from 'hooks/useActiveChainId' +import { useCakePrice } from 'hooks/useCakePrice' +import { useLpTokenPrice } from 'state/farms/hooks' +import { publicClient } from 'utils/wagmi' + +import { PublicIfoData } from '../../types' +import { getStatusByTimestamp } from '../helpers' + +// https://github.com/pancakeswap/pancake-contracts/blob/master/projects/ifo/contracts/IFOV2.sol#L431 +// 1,000,000,000 / 100 +const TAX_PRECISION = new BigNumber(10000000000) + +const NO_QUALIFIED_NFT_ADDRESS = '0x0000000000000000000000000000000000000000' + +const formatPool = (pool: readonly [bigint, bigint, bigint, boolean, bigint, bigint, number]) => ({ + raisingAmountPool: pool ? new BigNumber(pool[0].toString()) : BIG_ZERO, + offeringAmountPool: pool ? new BigNumber(pool[1].toString()) : BIG_ZERO, + limitPerUserInLP: pool ? new BigNumber(pool[2].toString()) : BIG_ZERO, + hasTax: pool ? pool[3] : false, + totalAmountPool: pool ? new BigNumber(pool[4].toString()) : BIG_ZERO, + sumTaxesOverflow: pool ? new BigNumber(pool[5].toString()) : BIG_ZERO, + saleType: pool ? pool[6] : 0, +}) + +const formatVestingInfo = (pool: readonly [bigint, bigint, bigint, bigint]) => ({ + percentage: pool ? Number(pool[0]) : 0, + cliff: pool ? Number(pool[1]) : 0, + duration: pool ? Number(pool[2]) : 0, + slicePeriodSeconds: pool ? Number(pool[3]) : 0, +}) + +const ROUND_DIGIT = 3 + +const INITIAL_STATE = { + isInitialized: false, + status: 'idle' as IfoStatus, + secondsUntilStart: 0, + progress: 5, + secondsUntilEnd: 0, + poolBasic: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + pointThreshold: 0, + admissionProfile: undefined, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + + // 0: public sale + // 1: private sale + // 2: basic sale + saleType: undefined, + }, + poolUnlimited: { + raisingAmountPool: BIG_ZERO, + offeringAmountPool: BIG_ZERO, + limitPerUserInLP: BIG_ZERO, + taxRate: 0, + distributionRatio: 0, + totalAmountPool: BIG_ZERO, + sumTaxesOverflow: BIG_ZERO, + vestingInformation: { + percentage: 0, + cliff: 0, + duration: 0, + slicePeriodSeconds: 0, + }, + saleType: undefined, + }, + thresholdPoints: undefined, + startTimestamp: 0, + endTimestamp: 0, + numberPoints: 0, + vestingStartTime: 0, + plannedStartTime: 0, +} + +/** + * Gets all public data of an IFO + */ +const useGetPublicIfoData = (ifo: Ifo): PublicIfoData => { + const { chainId: currentChainId } = useActiveChainId() + const { address: account } = useAccount() + const { chainId } = ifo + const { address, plannedStartTime } = ifo + const cakePrice = useCakePrice() + const lpTokenPriceInUsd = useLpTokenPrice(ifo.currency.symbol) + const currencyPriceInUSD = ifo.currency === CAKE[ifo.chainId] ? cakePrice : lpTokenPriceInUsd + + const [state, setState] = useState(INITIAL_STATE) + + const fetchIfoData = useCallback(async () => { + const client = publicClient({ chainId }) + const [ + [startTimestamp, endTimestamp, poolBasic, poolUnlimited, taxRate, pointConfig, privateSaleTaxRate], + [admissionProfile, pointThreshold, vestingStartTime, basicVestingInformation, unlimitedVestingInformation], + ] = await Promise.all([ + client.multicall({ + contracts: [ + { + abi: ifoV8ABI, + address, + functionName: 'startTimestamp', + }, + { + abi: ifoV8ABI, + address, + functionName: 'endTimestamp', + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolInformation', + args: [0n], + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolInformation', + args: [1n], + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [1n], + }, + { + abi: ifoV8ABI, + address, + functionName: 'pointConfig', + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolTaxRateOverflow', + args: [0n], + }, + ], + allowFailure: false, + }), + client.multicall({ + contracts: [ + { + abi: ifoV8ABI, + address, + functionName: 'addresses', + args: [5n], + }, + { + abi: ifoV8ABI, + address, + functionName: 'pointThreshold', + }, + { + abi: ifoV8ABI, + address, + functionName: 'vestingStartTime', + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [0n], + }, + { + abi: ifoV8ABI, + address, + functionName: 'viewPoolVestingInformation', + args: [1n], + }, + ], + allowFailure: true, + }), + ]) + const [, numberPoints, thresholdPoints] = pointConfig + + const poolBasicFormatted = formatPool(poolBasic) + const poolUnlimitedFormatted = formatPool(poolUnlimited) + + const startTime = Number(startTimestamp) || 0 + const endTime = Number(endTimestamp) || 0 + const taxRateNum = taxRate ? new BigNumber(taxRate.toString()).div(TAX_PRECISION).toNumber() : 0 + const privateSaleTaxRateNum = privateSaleTaxRate + ? new BigNumber(privateSaleTaxRate.toString()).div(TAX_PRECISION).toNumber() + : 0 + + const now = Math.floor(Date.now() / 1000) + const status = getStatusByTimestamp(now, startTime, endTime) + const duration = endTime - startTime + const secondsUntilEnd = endTime - now + + // Calculate the total progress until finished or until start + const progress = status === 'live' ? ((now - startTime) / duration) * 100 : null + + const totalOfferingAmount = poolBasicFormatted.offeringAmountPool.plus(poolUnlimitedFormatted.offeringAmountPool) + + setState( + (prev) => + ({ + ...prev, + isInitialized: true, + secondsUntilEnd, + secondsUntilStart: startTime - now, + poolBasic: { + ...poolBasicFormatted, + taxRate: privateSaleTaxRateNum, + distributionRatio: round( + poolBasicFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + pointThreshold: pointThreshold.result ? Number(pointThreshold.result) : 0, + admissionProfile: + Boolean(admissionProfile && admissionProfile.result) && + admissionProfile.result !== NO_QUALIFIED_NFT_ADDRESS + ? admissionProfile.result + : undefined, + vestingInformation: formatVestingInfo(basicVestingInformation.result || [0n, 0n, 0n, 0n]), + }, + poolUnlimited: { + ...poolUnlimitedFormatted, + taxRate: taxRateNum, + distributionRatio: round( + poolUnlimitedFormatted.offeringAmountPool.div(totalOfferingAmount).toNumber(), + ROUND_DIGIT, + ), + vestingInformation: formatVestingInfo(unlimitedVestingInformation.result || [0n, 0n, 0n, 0n]), + }, + status, + progress, + startTimestamp: startTime, + endTimestamp: endTime, + thresholdPoints, + numberPoints: numberPoints ? Number(numberPoints) : 0, + plannedStartTime: plannedStartTime ?? 0, + vestingStartTime: vestingStartTime.result ? Number(vestingStartTime.result) : 0, + } as any), + ) + }, [plannedStartTime, address, chainId]) + + useEffect(() => setState(INITIAL_STATE), [currentChainId, account]) + + return { ...state, currencyPriceInUSD, fetchIfoData } as any +} + +export default useGetPublicIfoData diff --git a/apps/web/src/views/Idos/hooks/v8/useGetWalletIfoData.ts b/apps/web/src/views/Idos/hooks/v8/useGetWalletIfoData.ts new file mode 100644 index 0000000000000..2b7af58d91510 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/v8/useGetWalletIfoData.ts @@ -0,0 +1,284 @@ +import { Ifo, PoolIds, ifoV8ABI } from '@pancakeswap/ifos' +import { BIG_ZERO } from '@pancakeswap/utils/bigNumber' +import BigNumber from 'bignumber.js' +import { useERC20, useIfoV8Contract } from 'hooks/useContract' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useAppDispatch } from 'state' +import { fetchCakeVaultUserData } from 'state/pools' +import { publicClient } from 'utils/wagmi' +import { Address } from 'viem' +import { useAccount } from 'wagmi' + +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { WalletIfoData, WalletIfoState } from '../../types' +import useIfoAllowance from '../useIfoAllowance' +import { useIfoCredit } from '../useIfoCredit' +import { useIfoSourceChain } from '../useIfoSourceChain' + +const initialState = { + isInitialized: false, + poolBasic: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, + poolUnlimited: { + amountTokenCommittedInLP: BIG_ZERO, + offeringAmountInToken: BIG_ZERO, + refundingAmountInLP: BIG_ZERO, + taxAmountInLP: BIG_ZERO, + hasClaimed: false, + isPendingTx: false, + vestingReleased: BIG_ZERO, + vestingAmountTotal: BIG_ZERO, + isVestingInitialized: false, + vestingId: '0', + vestingComputeReleasableAmount: BIG_ZERO, + }, +} + +/** + * Gets all data from an IFO related to a wallet + */ +const useGetWalletIfoData = (ifo: Ifo): WalletIfoData => { + const { chainId: currentChainId } = useActiveChainId() + const [state, setState] = useState(initialState) + const dispatch = useAppDispatch() + const { chainId } = ifo + const creditAmount = useIfoCredit({ chainId, ifoAddress: ifo.address }) + const credit = useMemo( + () => (creditAmount && BigNumber(creditAmount.quotient.toString())) ?? BIG_ZERO, + [creditAmount], + ) + const sourceChain = useIfoSourceChain(chainId) + + const { address, currency, version } = ifo + + const { address: account } = useAccount() + const contract = useIfoV8Contract(address, { chainId }) + const currencyContract = useERC20(currency.address, { chainId }) + const allowance = useIfoAllowance(currencyContract, address) + + const setPendingTx = (status: boolean, poolId: PoolIds) => + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + isPendingTx: status, + }, + })) + + const setIsClaimed = (poolId: PoolIds) => { + setState((prevState) => ({ + ...prevState, + [poolId]: { + ...prevState[poolId], + hasClaimed: true, + }, + })) + } + + const fetchIfoData = useCallback(async () => { + if (!account) { + return + } + const client = publicClient({ chainId }) + + const [userInfo, amounts] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV8ABI, + functionName: 'viewUserInfo', + args: [account, [0, 1]], + }, + { + address, + abi: ifoV8ABI, + functionName: 'viewUserOfferingAndRefundingAmountsForPools', + args: [account, [0, 1]], + }, + ], + allowFailure: false, + }) + + let basicId: Address | null = null + let unlimitedId: Address | null = null + if (version >= 3.2) { + const [basicIdDataResult, unlimitedIdDataResult] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV8ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 0], + }, + { + address, + abi: ifoV8ABI, + functionName: 'computeVestingScheduleIdForAddressAndPid', + args: [account, 1], + }, + ], + }) + + basicId = basicIdDataResult.result ?? null + unlimitedId = unlimitedIdDataResult.result ?? null + } + + basicId = basicId || '0x' + unlimitedId = unlimitedId || '0x' + + let [ + isQualifiedNFT, + isQualifiedPoints, + basicSchedule, + unlimitedSchedule, + basicReleasableAmount, + unlimitedReleasableAmount, + ]: [boolean | undefined, boolean | undefined, any | null, any | null, any | null, any | null] = [ + false, + false, + null, + null, + null, + null, + ] + + if (version >= 3.1) { + const [ + isQualifiedNFTResult, + isQualifiedPointsResult, + basicScheduleResult, + unlimitedScheduleResult, + basicReleasableAmountResult, + unlimitedReleasableAmountResult, + ] = await client.multicall({ + contracts: [ + { + address, + abi: ifoV8ABI, + functionName: 'isQualifiedNFT', + args: [account], + }, + { + abi: ifoV8ABI, + address, + functionName: 'isQualifiedPoints', + args: [account], + }, + { + abi: ifoV8ABI, + address, + functionName: 'getVestingSchedule', + args: [basicId], + }, + { + abi: ifoV8ABI, + address, + functionName: 'getVestingSchedule', + args: [unlimitedId], + }, + { + abi: ifoV8ABI, + address, + functionName: 'computeReleasableAmount', + args: [basicId], + }, + { + abi: ifoV8ABI, + address, + functionName: 'computeReleasableAmount', + args: [unlimitedId], + }, + ], + allowFailure: true, + }) + + isQualifiedNFT = isQualifiedNFTResult.result + isQualifiedPoints = isQualifiedPointsResult.result + basicSchedule = basicScheduleResult.result + unlimitedSchedule = unlimitedScheduleResult.result + basicReleasableAmount = basicReleasableAmountResult.result + unlimitedReleasableAmount = unlimitedReleasableAmountResult.result + } + + dispatch(fetchCakeVaultUserData({ account, chainId: sourceChain })) + + setState( + (prevState) => + ({ + ...prevState, + isInitialized: true, + poolBasic: { + ...prevState.poolBasic, + amountTokenCommittedInLP: new BigNumber(userInfo[0][0].toString()), + offeringAmountInToken: new BigNumber(amounts[0][0].toString()), + refundingAmountInLP: new BigNumber(amounts[0][1].toString()), + taxAmountInLP: new BigNumber(amounts[0][2].toString()), + hasClaimed: userInfo[1][0], + isQualifiedNFT, + isQualifiedPoints, + vestingReleased: basicSchedule ? new BigNumber(basicSchedule.released.toString()) : BIG_ZERO, + vestingAmountTotal: basicSchedule ? new BigNumber(basicSchedule.amountTotal.toString()) : BIG_ZERO, + isVestingInitialized: basicSchedule ? basicSchedule.isVestingInitialized : false, + vestingId: basicId ? basicId.toString() : '0', + vestingComputeReleasableAmount: basicReleasableAmount + ? new BigNumber(basicReleasableAmount.toString()) + : BIG_ZERO, + }, + poolUnlimited: { + ...prevState.poolUnlimited, + amountTokenCommittedInLP: new BigNumber(userInfo[0][1].toString()), + offeringAmountInToken: new BigNumber(amounts[1][0].toString()), + refundingAmountInLP: new BigNumber(amounts[1][1].toString()), + taxAmountInLP: new BigNumber(amounts[1][2].toString()), + hasClaimed: userInfo[1][1], + vestingReleased: unlimitedSchedule ? new BigNumber(unlimitedSchedule.released.toString()) : BIG_ZERO, + vestingAmountTotal: unlimitedSchedule ? new BigNumber(unlimitedSchedule.amountTotal.toString()) : BIG_ZERO, + isVestingInitialized: unlimitedSchedule ? unlimitedSchedule.isVestingInitialized : false, + vestingId: unlimitedId ? unlimitedId.toString() : '0', + vestingComputeReleasableAmount: unlimitedReleasableAmount + ? new BigNumber(unlimitedReleasableAmount.toString()) + : BIG_ZERO, + }, + } as any), + ) + }, [account, address, dispatch, version, chainId, sourceChain]) + + const resetIfoData = useCallback(() => { + setState({ ...initialState }) + }, []) + + const creditLeftWithNegative = credit.minus(state.poolUnlimited.amountTokenCommittedInLP) + + const ifoCredit = { + credit, + creditLeft: BigNumber.maximum(BIG_ZERO, creditLeftWithNegative), + } + + useEffect(() => resetIfoData(), [currentChainId, account, resetIfoData]) + + return { + ...state, + allowance, + contract, + setPendingTx, + setIsClaimed, + fetchIfoData, + resetIfoData, + ifoCredit, + version: 8, + } +} + +export default useGetWalletIfoData diff --git a/apps/web/src/views/Idos/hooks/vesting/fetchUserWalletIfoData.ts b/apps/web/src/views/Idos/hooks/vesting/fetchUserWalletIfoData.ts new file mode 100644 index 0000000000000..be64ef9c0a8d0 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/vesting/fetchUserWalletIfoData.ts @@ -0,0 +1,33 @@ +import { + Ifo, + UserVestingData, + VestingCharacteristics, + fetchUserVestingData, + fetchUserVestingDataV8, +} from '@pancakeswap/ifos' +import { Address } from 'viem' + +import { getViemClients } from 'utils/viem' + +export type { VestingCharacteristics } + +export interface VestingData { + ifo: Ifo + userVestingData: UserVestingData +} + +export const fetchUserWalletIfoData = async (ifo: Ifo, account?: Address): Promise => { + const { address, chainId, version } = ifo + const fetchUserData = version >= 8 ? fetchUserVestingDataV8 : fetchUserVestingData + const userVestingData = await fetchUserData({ + ifoAddress: address, + chainId, + account, + provider: getViemClients, + }) + + return { + ifo, + userVestingData, + } +} diff --git a/apps/web/src/views/Idos/hooks/vesting/useFetchVestingData.ts b/apps/web/src/views/Idos/hooks/vesting/useFetchVestingData.ts new file mode 100644 index 0000000000000..b60a6d9f92414 --- /dev/null +++ b/apps/web/src/views/Idos/hooks/vesting/useFetchVestingData.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import { useAccount } from 'wagmi' +import { Ifo, PoolIds } from '@pancakeswap/ifos' +import BigNumber from 'bignumber.js' +import { useQuery } from '@tanstack/react-query' + +import { useIfoConfigsAcrossChains } from 'hooks/useIfoConfig' +import { FAST_INTERVAL } from 'config/constants' +import { useActiveChainId } from 'hooks/useActiveChainId' + +import { fetchUserWalletIfoData } from './fetchUserWalletIfoData' + +const useFetchVestingData = () => { + const { address: account } = useAccount() + const { chainId } = useActiveChainId() + const configs = useIfoConfigsAcrossChains() + const allVestingIfo = useMemo( + () => configs?.filter((ifo) => ifo.version >= 3.2 && ifo.vestingTitle) || [], + [configs], + ) + + const { data: vestingData, refetch } = useQuery({ + queryKey: ['vestingData', account], + + queryFn: async () => { + const allData = await Promise.all( + allVestingIfo.map(async (ifo) => { + const response = await fetchUserWalletIfoData(ifo, account) + return response + }), + ) + + const currentTimeStamp = Date.now() + + return allData.filter( + // eslint-disable-next-line array-callback-return, consistent-return + (ifo) => { + const { userVestingData } = ifo + if ( + userVestingData[PoolIds.poolBasic].offeringAmountInToken.gt(0) || + userVestingData[PoolIds.poolUnlimited].offeringAmountInToken.gt(0) + ) { + if ( + userVestingData[PoolIds.poolBasic].vestingComputeReleasableAmount.gt(0) || + userVestingData[PoolIds.poolUnlimited].vestingComputeReleasableAmount.gt(0) + ) { + return ifo + } + const vestingStartTime = new BigNumber(userVestingData.vestingStartTime) + const isPoolUnlimitedLive = vestingStartTime + .plus(userVestingData[PoolIds.poolUnlimited].vestingInformationDuration) + .times(1000) + .gte(currentTimeStamp) + if (isPoolUnlimitedLive) return ifo + const isPoolBasicLive = vestingStartTime + .plus(userVestingData[PoolIds.poolBasic].vestingInformationDuration) + .times(1000) + .gte(currentTimeStamp) + if (isPoolBasicLive) return ifo + return false + } + return false + }, + ) + }, + + enabled: Boolean(account), + refetchOnWindowFocus: false, + refetchInterval: FAST_INTERVAL, + staleTime: FAST_INTERVAL, + }) + + // Sort by active chain + const data = useMemo( + () => + vestingData && + vestingData.toSorted((a, b) => { + if (a.ifo.chainId === chainId && b.ifo.chainId !== chainId) { + return -1 + } + if (a.ifo.chainId !== chainId && b.ifo.chainId === chainId) { + return 1 + } + return 0 + }), + [chainId, vestingData], + ) + + return { + data: data || [], + fetchUserVestingData: refetch, + } +} + +export default useFetchVestingData diff --git a/apps/web/src/views/Idos/ido.tsx b/apps/web/src/views/Idos/ido.tsx new file mode 100644 index 0000000000000..a614831c40f54 --- /dev/null +++ b/apps/web/src/views/Idos/ido.tsx @@ -0,0 +1,12 @@ +import { useActiveIfoConfig } from 'hooks/useIfoConfig' + +import CurrentIfo from './CurrentIfo' +import { IfoPlaceholder } from './IfoPlaceholder' +import SoonIfo from './SoonIfo' + +const Ifo = () => { + const { activeIfo, isPending } = useActiveIfoConfig() + return activeIfo ? : isPending ? : +} + +export default Ifo diff --git a/apps/web/src/views/Idos/index.tsx b/apps/web/src/views/Idos/index.tsx new file mode 100644 index 0000000000000..cc3b08fe098d4 --- /dev/null +++ b/apps/web/src/views/Idos/index.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react' +import { useModal } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import { useUserNotUsCitizenAcknowledgement, IdType } from 'hooks/useUserIsUsCitizenAcknowledgement' +import USCitizenConfirmModal from 'components/Modal/USCitizenConfirmModal' +import Hero from './components/Hero' +import IfoProvider from './contexts/IfoContext' + +export const IfoPageLayout = ({ children }) => { + const { t } = useTranslation() + + const [userNotUsCitizenAcknowledgement] = useUserNotUsCitizenAcknowledgement(IdType.IFO) + const [onUSCitizenModalPresent] = useModal( + , + false, + false, + 'usCitizenConfirmModal', + ) + + useEffect(() => { + const timer = setTimeout(() => { + if (!userNotUsCitizenAcknowledgement) { + onUSCitizenModalPresent() + } + }, 2000) + + return () => clearTimeout(timer) + }, [userNotUsCitizenAcknowledgement, onUSCitizenModalPresent]) + + return ( + + + {children} + + ) +} diff --git a/apps/web/src/views/Idos/types.ts b/apps/web/src/views/Idos/types.ts new file mode 100644 index 0000000000000..c51e1ad9f8535 --- /dev/null +++ b/apps/web/src/views/Idos/types.ts @@ -0,0 +1,133 @@ +import { IfoStatus, PoolIds } from '@pancakeswap/ifos' +import BigNumber from 'bignumber.js' + +import { + useIfoV1Contract, + useIfoV2Contract, + useIfoV3Contract, + useIfoV7Contract, + useIfoV8Contract, +} from 'hooks/useContract' + +// PoolCharacteristics retrieved from the contract +export interface PoolCharacteristics { + raisingAmountPool: BigNumber + offeringAmountPool: BigNumber + limitPerUserInLP: BigNumber + taxRate: number + totalAmountPool: BigNumber + sumTaxesOverflow: BigNumber + + // extends + pointThreshold?: number + distributionRatio?: number + admissionProfile?: string + needQualifiedNFT?: boolean + needQualifiedPoints?: boolean + vestingInformation?: VestingInformation + hasTax?: boolean + + // 0: public sale + // 1: private sale + // 2: basic sale + saleType?: number +} + +// IFO data unrelated to the user returned by useGetPublicIfoData +export interface PublicIfoData { + isInitialized: boolean + status: IfoStatus + blocksRemaining?: number + secondsUntilStart: number + progress: number + secondsUntilEnd: number + startBlockNum?: number + endBlockNum?: number + currencyPriceInUSD: BigNumber + numberPoints: number + thresholdPoints: bigint + plannedStartTime?: number + vestingStartTime?: number + + fetchIfoData: (currentBlock: number) => Promise + [PoolIds.poolBasic]?: PoolCharacteristics + [PoolIds.poolUnlimited]: PoolCharacteristics + + startTimestamp?: number + endTimestamp?: number +} + +export interface VestingInformation { + percentage: number + cliff: number + duration: number + slicePeriodSeconds: number +} + +// User specific pool characteristics +export interface UserPoolCharacteristics { + amountTokenCommittedInLP: BigNumber // @contract: amountPool + offeringAmountInToken: BigNumber // @contract: userOfferingAmountPool + refundingAmountInLP: BigNumber // @contract: userRefundingAmountPool + taxAmountInLP: BigNumber // @contract: userTaxAmountPool + hasClaimed: boolean // @contract: claimedPool + isPendingTx: boolean + vestingReleased?: BigNumber + vestingAmountTotal?: BigNumber + isVestingInitialized?: boolean + vestingId?: string + vestingComputeReleasableAmount?: BigNumber +} + +// Use only inside the useGetWalletIfoData hook +export interface WalletIfoState { + isInitialized: boolean + [PoolIds.poolBasic]?: UserPoolCharacteristics + [PoolIds.poolUnlimited]: UserPoolCharacteristics + ifoCredit?: { + credit: BigNumber + /** + * credit left is the ifo credit minus the amount of `amountTokenCommittedInLP` in unlimited pool + * minimum is 0 + */ + creditLeft: BigNumber + } +} + +type WalletIfoBase = { + allowance: BigNumber + setPendingTx: (status: boolean, poolId: PoolIds) => void + setIsClaimed: (poolId: PoolIds) => void + fetchIfoData: () => Promise + resetIfoData: () => void +} & WalletIfoState + +// Returned by useGetWalletIfoData +export type WalletIfoData = WalletIfoBase & WalletIfoContract + +type WalletIfoDataV1Contract = { + version: 1 + contract: ReturnType +} +type WalletIfoDataV2Contract = { + version: 2 + contract: ReturnType +} +type WalletIfoDataV3Contract = { + version: 3 + contract: ReturnType +} +type WalletIfoDataV7Contract = { + version: 7 + contract: ReturnType +} +type WalletIfoDataV8Contract = { + version: 8 + contract: ReturnType +} +type WalletIfoContract = + | WalletIfoDataV1Contract + | WalletIfoDataV2Contract + | WalletIfoDataV3Contract + | WalletIfoDataV7Contract + | WalletIfoDataV8Contract