diff --git a/src/app/components/tabBar/index.tsx b/src/app/components/tabBar/index.tsx index d6fade869..65e096975 100644 --- a/src/app/components/tabBar/index.tsx +++ b/src/app/components/tabBar/index.tsx @@ -42,7 +42,7 @@ function BottomTabBar({ tab }:Props) { }; const handleStackingButtonClick = () => { - + if (tab !== 'stacking') { navigate('/stacking'); } }; const handleSettingButtonClick = () => { diff --git a/src/app/hooks/useStackingData.tsx b/src/app/hooks/useStackingData.tsx new file mode 100644 index 000000000..f30f0dacb --- /dev/null +++ b/src/app/hooks/useStackingData.tsx @@ -0,0 +1,58 @@ +import { useQueries } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { + fetchDelegationState, fetchPoolStackerInfo, fetchStackingPoolInfo, getStacksInfo, StackingData, +} from '@secretkeylabs/xverse-core'; +import useWalletSelector from './useWalletSelector'; + +const useStackingData = () => { + const { stxAddress, network } = useWalletSelector(); + + const results = useQueries({ + queries: [ + { + queryKey: ['stacking-core-info', network], + queryFn: () => getStacksInfo(network.address), + }, + { + queryKey: ['stacking-delegation-state', stxAddress, network], + queryFn: () => fetchDelegationState(stxAddress, network), + }, + { + queryKey: ['stacking-pool-info'], + queryFn: () => fetchStackingPoolInfo(), + }, + { + queryKey: ['pool-stacker-info', stxAddress], + queryFn: () => fetchPoolStackerInfo(stxAddress), + }, + ], + }); + + const coreInfoData = results[0].data; + const delegationStateData = results[1].data; + const poolInfoData = results[2].data; + const stackerInfoData = results[3].data; + + const isStackingLoading = results.some((result) => result.isLoading); + const stackingError = results.find(({ error }) => error != null)?.error; + const refetchStackingData = useCallback(() => { + results.forEach((result) => result.refetch()); + }, [results]); + + const stackingData: StackingData = { + poolInfo: poolInfoData, + delegationInfo: delegationStateData!, + coreInfo: coreInfoData!, + stackerInfo: stackerInfoData, + }; + + return { + isStackingLoading, + stackingError, + stackingData, + refetchStackingData, + }; +}; + +export default useStackingData; diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index e7f69843d..db1ad2bc5 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -20,6 +20,7 @@ import Login from '@screens/login'; import RestoreWallet from '@screens/restoreWallet'; import ForgotPassword from '@screens/forgotPassword'; import BackupWalletSteps from '@screens/backupWalletSteps'; +import Stacking from '@screens/stacking'; import NftDashboard from '@screens/nftDashboard'; import NftDetailScreen from '@screens/nftDetail'; import Setting from '@screens/settings'; @@ -119,6 +120,10 @@ const router = createHashRouter([ path: 'backupWalletSteps', element: , }, + { + path: 'stacking', + element: , + }, { path: 'nft-dashboard', element: , diff --git a/src/app/screens/stacking/index.tsx b/src/app/screens/stacking/index.tsx new file mode 100644 index 000000000..fbbc18e17 --- /dev/null +++ b/src/app/screens/stacking/index.tsx @@ -0,0 +1,63 @@ +import { useNavigate } from 'react-router-dom'; +import { Ring } from 'react-spinners-css'; +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import useStackingData from '@hooks/useStackingData'; +import useWalletSelector from '@hooks/useWalletSelector'; +import AccountRow from '@components/accountRow'; +import BottomBar from '@components/tabBar'; +import StackingProgress from './stackingProgress'; +import StartStacking from './startStacking'; + +const SelectedAccountContainer = styled.div((props) => ({ + marginLeft: props.theme.spacing(8), + marginRight: props.theme.spacing(8), +})); + +const LoaderContainer = styled.div((props) => ({ + display: 'flex', + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: props.theme.spacing(12), +})); + +function Stacking() { + const { selectedAccount } = useWalletSelector(); + const { isStackingLoading, stackingData } = useStackingData(); + const navigate = useNavigate(); + const [isStacking, setIsStacking] = useState(false); + const handleAccountSelect = () => { + navigate('/account-list'); + }; + + useEffect(() => { + if (stackingData) { + if (stackingData?.stackerInfo?.stacked || stackingData?.delegationInfo?.delegated) { + setIsStacking(true); + } + } + }, [stackingData]); + + const showStatus = !isStackingLoading && ( + isStacking ? : + ); + + return ( + <> + + + + {isStackingLoading && ( + + + + ) } + {showStatus} + + + + ); +} + +export default Stacking; diff --git a/src/app/screens/stacking/stackingProgress/index.tsx b/src/app/screens/stacking/stackingProgress/index.tsx new file mode 100644 index 000000000..57139b10c --- /dev/null +++ b/src/app/screens/stacking/stackingProgress/index.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import StackingStatusTile from './stackingStatusTile'; + +const Container = styled.div((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'column', + margin: props.theme.spacing(8), + borderTop: `1px solid ${props.theme.colors.background.elevation3}`, +})); + +const TitleText = styled.h1((props) => ({ + ...props.theme.headline_s, + marginTop: props.theme.spacing(16), +})); + +const StackingDescriptionText = styled.h1((props) => ({ + ...props.theme.body_m, + marginTop: props.theme.spacing(4), + marginBottom: props.theme.spacing(18), + color: props.theme.colors.white['200'], +})); +function StackingProgress() { + const { t } = useTranslation('translation', { keyPrefix: 'STACKING_SCREEN' }); + + return ( + + {t('STACK_FOR_REWARD')} + {t('XVERSE_POOL')} + + + ); +} + +export default StackingProgress; diff --git a/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx b/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx new file mode 100644 index 000000000..a8e3752c8 --- /dev/null +++ b/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx @@ -0,0 +1,106 @@ +import styled from 'styled-components'; +import TokenTicker from '@assets/img/stacking/token_ticker.svg'; +import { useTranslation } from 'react-i18next'; +import useStackingData from '@hooks/useStackingData'; +import { StackingState } from '@secretkeylabs/xverse-core/stacking'; +import { useEffect, useState } from 'react'; +import { XVERSE_WEB_POOL_URL } from '@utils/constants'; + +const Container = styled.button((props) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + background: props.theme.colors.background.elevation1, + borderRadius: props.theme.radius(2), + padding: props.theme.spacing(9), +})); + +const TextContainer = styled.div({ + display: 'flex', + flexDirection: 'row', + flex: 1, +}); + +const StatusContainer = styled.div((props) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + background: 'rgba(81, 214, 166, 0.15)', + borderRadius: props.theme.radius(7), + paddingLeft: props.theme.spacing(6), + paddingRight: props.theme.spacing(6), + paddingTop: props.theme.spacing(2), + paddingBottom: props.theme.spacing(2), +})); + +const Dot = styled.div((props) => ({ + width: 7, + height: 7, + borderRadius: props.theme.radius(9), + marginRight: props.theme.spacing(4), + background: props.theme.colors.feedback.success, +})); + +const ColumnContainer = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + marginLeft: props.theme.spacing(6), +})); + +const BoldText = styled.h1((props) => ({ + ...props.theme.body_bold_m, + color: props.theme.colors.white['0'], +})); + +const SubText = styled.h1((props) => ({ + ...props.theme.body_xs, + color: props.theme.colors.white['400'], +})); + +const StatusText = styled.h1((props) => ({ + ...props.theme.body_xs, + fontWeight: 500, + color: props.theme.colors.white['0'], +})); + +function StackingStatusTile() { + const { t } = useTranslation('translation', { keyPrefix: 'STACKING_SCREEN' }); + const { stackingData } = useStackingData(); + const [status, setStatus] = useState('Pending'); + + useEffect(() => { + if (stackingData) { + if (!stackingData?.stackerInfo?.stacked && stackingData?.delegationInfo?.delegated) { + setStatus('Delegated'); + } else if (stackingData?.stackerInfo?.stacked) { setStatus('Stacking'); } + } + }, [stackingData]); + + const handleOnClick = () => { + window.open(XVERSE_WEB_POOL_URL); + }; + + return ( + + + Ticker + + {t('STACK_STX')} + {t('EARN_BTC')} + + + + + + + {status === 'Stacking' ? t('IN_PROGRESS') : t('DELEGATED')} + + + + ); +} + +export default StackingStatusTile; diff --git a/src/app/screens/stacking/startStacking/index.tsx b/src/app/screens/stacking/startStacking/index.tsx new file mode 100644 index 000000000..d623a9066 --- /dev/null +++ b/src/app/screens/stacking/startStacking/index.tsx @@ -0,0 +1,109 @@ +import styled, { useTheme } from 'styled-components'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { microstacksToStx } from '@secretkeylabs/xverse-core'; +import { Pool } from '@secretkeylabs/xverse-core/types'; +import BigNumber from 'bignumber.js'; +import ArrowSquareOut from '@assets/img/arrow_square_out.svg'; +import ActionButton from '@components/button'; +import useStackingData from '@hooks/useStackingData'; +import { XVERSE_WEB_POOL_URL } from '@utils/constants'; +import StackingInfoTile from './stackInfoTile'; + +const Container = styled.div((props) => ({ + margin: props.theme.spacing(8), + borderBottom: `1px solid ${props.theme.colors.background.elevation3}`, + borderTop: `1px solid ${props.theme.colors.background.elevation3}`, +})); + +const StackingInfoContainer = styled.div((props) => ({ + marginLeft: props.theme.spacing(8), + marginRight: props.theme.spacing(30), + display: 'flex', + flexDirection: 'column', + flex: 1, +})); + +const ButtonContainer = styled.div((props) => ({ + margin: props.theme.spacing(8), + +})); + +const RowContainer = styled.div((props) => ({ + marginTop: props.theme.spacing(12), + display: 'flex', + flexDirection: 'row', + flex: 1, + justifyContent: 'space-between', +})); + +const TitleText = styled.h1((props) => ({ + ...props.theme.headline_s, + marginTop: props.theme.spacing(16), +})); + +const StackingDescriptionText = styled.h1((props) => ({ + ...props.theme.body_m, + marginTop: props.theme.spacing(4), + marginBottom: props.theme.spacing(12), + color: props.theme.colors.white['200'], +})); + +function StartStacking() { + const { t } = useTranslation('translation', { keyPrefix: 'STACKING_SCREEN' }); + const [poolAvailable, setPoolAvailable] = useState(false); + const { stackingData } = useStackingData(); + + const theme = useTheme(); + const [pool, setPool] = useState(undefined); + + useEffect(() => { + if (stackingData) { + if (stackingData?.poolInfo?.pools?.length! > 0) { + const pools = stackingData?.poolInfo?.pools!; + setPool(pools[0]); + setPoolAvailable(true); + } + } + }, [stackingData]); + + function getMinimum() { + const min = poolAvailable ? pool!.minimum : 0; + return microstacksToStx(new BigNumber(min)).toString(); + } + + function getCycles() { + const minCycles = poolAvailable ? Math.min(...pool!.available_cycle_durations) : 0; + const maxCycles = poolAvailable ? Math.max(...pool!.available_cycle_durations) : 0; + return `${minCycles}-${maxCycles}`; + } + + const handleOnClick = () => { + window.open(XVERSE_WEB_POOL_URL); + }; + + return ( + <> + + {t('STACK_AND_EARN')} + {t('STACKING_INFO')} + + + + + + + + + + + + + + + + + ); +} + +export default StartStacking; diff --git a/src/app/screens/stacking/startStacking/stackInfoTile.tsx b/src/app/screens/stacking/startStacking/stackInfoTile.tsx new file mode 100644 index 000000000..e71d9f04e --- /dev/null +++ b/src/app/screens/stacking/startStacking/stackInfoTile.tsx @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import BarLoader from '@components/barLoader'; +import { LoaderSize } from '@utils/constants'; + +const TitleText = styled.h1((props) => ({ + ...props.theme.headline_category_s, + textTransform: 'uppercase', + letterSpacing: '0.02em', + color: props.theme.colors.white['400'], +})); + +const Container = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const ValueText = styled.h1((props) => ({ + ...props.theme.body_bold_l, + marginTop: props.theme.spacing(2), + color: props.color ?? props.theme.colors.white['0'], +})); + +const BarLoaderContainer = styled.div((props) => ({ + marginTop: props.theme.spacing(5), +})); + +interface Props { + title: string; + value: string | undefined; + color?:string; +} + +function StackingInfoTile({ title, value, color }: Props) { + return ( + + {title} + {value ? {value} : ( + + + + )} + + ); +} + +export default StackingInfoTile; diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index cf70a1165..c6c5c154f 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -7,6 +7,7 @@ export const PRIVACY_POLICY_LINK = 'https://xverse.app/privacy'; export const SUPPORT_LINK = 'https://support.xverse.app/hc/en-us'; export const BTC_TRANSACTION_STATUS_URL = 'https://www.blockchain.com/btc/tx/'; export const TRANSACTION_STATUS_URL = 'https://explorer.stacks.co/txid/'; +export const XVERSE_WEB_POOL_URL = 'https://pool.xverse.app'; export type CurrencyTypes = 'STX' | 'BTC' | 'FT' | 'NFT'; export enum LoaderSize { diff --git a/src/assets/img/stacking/token_ticker.svg b/src/assets/img/stacking/token_ticker.svg new file mode 100644 index 000000000..ee81043f2 --- /dev/null +++ b/src/assets/img/stacking/token_ticker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/locales/en.json b/src/locales/en.json index 018aa42b6..c150d340b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -169,6 +169,22 @@ "SEED_INPUT_ERROR": "Invalid seed phrase, please try again", "CONTINUE_BUTTON": "Continue" }, + "STACKING_SCREEN": { + "STACK_AND_EARN": "Stack STX, earn BTC", + "STACKING_INFO": "Pool your STX with other stackers to earn real BTC with low minimums. Rewards are distributed after the end of every 2-week cycle.", + "APY": "Estimated APY", + "POOL_FEE": "Pool fee", + "MINIMUM_AMOUNT": "Minimum amount", + "REWARD_CYCLES": "Reward Cycles", + "START_STACKNG": "Start stacking", + "STACK_FOR_REWARD": "Stack your coins to earn rewards", + "XVERSE_POOL": "Xverse delegated pooling allows everyone to participate in Stacking and earn rewards. ", + "STACK_STX": "Stack STX", + "EARN_BTC":"Earn BTC", + "PENDING_DELEGATION": "Pending delegation", + "DELEGATED": "Delegated", + "IN_PROGRESS": "In progress" + }, "NFT_DASHBOARD_SCREEN": { "COLLECTIBLES": "Number of collectibles", "NO_COLLECTIBLES": "No collectibles yet",