From 1fc4152f6b7496703b7a8fb7420b484dc242a2ee Mon Sep 17 00:00:00 2001 From: RanaBug Date: Tue, 20 May 2025 17:20:16 +0100 Subject: [PATCH 1/2] new wallet portfolio FE full implementation --- .../PrimeTokensBalance/PrimeTokensBalance.tsx | 87 ++++ .../pillarx-app/components/ReceiveModal.tsx | 122 +++++ .../TileContainer/TileContainer.tsx | 2 +- .../RightColumnTokenMarketDataRow.tsx | 5 +- .../TokenLogoMarketDataRow.tsx | 10 +- .../TokenMarketDataRow/TokenMarketDataRow.tsx | 2 +- .../components/TopTokens/TopTokens.tsx | 200 ++++++++ .../WalletConnectDropdown.tsx | 25 +- .../WalletPortfolioBalance.tsx | 165 ++++++ .../WalletPortfolioButtons.tsx | 31 ++ .../WalletPortfolioGraph/BalancePnlGraph.tsx | 474 ++++++++++++++++++ .../WalletPortfolioGraph.tsx | 155 ++++++ .../WalletPortfolioGraphButton.tsx | 25 + .../WalletPortfolioTile.tsx | 231 +++++++++ .../pillarx-app/hooks/useDataFetchingState.ts | 49 ++ .../pillarx-app/hooks/useReducerHooks.tsx | 6 + .../pillarx-app/images/prime-tokens-icon.png | Bin 0 -> 2121 bytes .../images/prime-tokens-question-icon.png | Bin 0 -> 978 bytes .../pillarx-app/images/refresh-button.png | Bin 0 -> 3294 bytes .../images/wallet-portfolio-icon.png | Bin 0 -> 3561 bytes src/apps/pillarx-app/index.tsx | 40 +- .../reducer/WalletPortfolioSlice.ts | 155 ++++++ .../pillarx-app/tailwind.pillarx.config.js | 6 +- src/apps/pillarx-app/utils/constants.ts | 13 + src/apps/pillarx-app/utils/portfolio.ts | 38 ++ .../DropdownTokensList/DropdownTokenList.tsx | 2 +- src/services/pillarXApiWalletHistory.ts | 63 +++ src/services/pillarXApiWalletPortfolio.ts | 108 +++- src/services/tokensData.ts | 5 + src/store.ts | 2 + src/theme/index.ts | 2 +- src/types/api.ts | 26 +- src/utils/number.tsx | 31 ++ 33 files changed, 2009 insertions(+), 71 deletions(-) create mode 100644 src/apps/pillarx-app/components/PrimeTokensBalance/PrimeTokensBalance.tsx create mode 100644 src/apps/pillarx-app/components/ReceiveModal.tsx create mode 100644 src/apps/pillarx-app/components/TopTokens/TopTokens.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraph.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx create mode 100644 src/apps/pillarx-app/hooks/useDataFetchingState.ts create mode 100644 src/apps/pillarx-app/hooks/useReducerHooks.tsx create mode 100644 src/apps/pillarx-app/images/prime-tokens-icon.png create mode 100644 src/apps/pillarx-app/images/prime-tokens-question-icon.png create mode 100644 src/apps/pillarx-app/images/refresh-button.png create mode 100644 src/apps/pillarx-app/images/wallet-portfolio-icon.png create mode 100644 src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts create mode 100644 src/apps/pillarx-app/utils/portfolio.ts create mode 100644 src/services/pillarXApiWalletHistory.ts diff --git a/src/apps/pillarx-app/components/PrimeTokensBalance/PrimeTokensBalance.tsx b/src/apps/pillarx-app/components/PrimeTokensBalance/PrimeTokensBalance.tsx new file mode 100644 index 00000000..c85a6d2b --- /dev/null +++ b/src/apps/pillarx-app/components/PrimeTokensBalance/PrimeTokensBalance.tsx @@ -0,0 +1,87 @@ +import { useMemo, useState } from 'react'; + +// services +import { getPrimeAssetsWithBalances } from '../../../../services/pillarXApiWalletPortfolio'; + +// types +import { PortfolioData } from '../../../../types/api'; + +// utils +import { limitDigitsNumber } from '../../../../utils/number'; +import { PRIME_ASSETS_MOBULA } from '../../utils/constants'; + +// reducer +import { useAppSelector } from '../../hooks/useReducerHooks'; + +// images +import PrimeTokensIcon from '../../images/prime-tokens-icon.png'; +import PrimeTokensQuestionIcon from '../../images/prime-tokens-question-icon.png'; + +// components +import BodySmall from '../Typography/BodySmall'; + +const PrimeTokensBalance = () => { + const [isHovered, setIsHovered] = useState(false); + + const walletPortfolio = useAppSelector( + (state) => + state.walletPortfolio.walletPortfolio as PortfolioData | undefined + ); + + const primeAssetsBalance = useMemo(() => { + if (!walletPortfolio) return undefined; + + const allPrimeAssets = getPrimeAssetsWithBalances( + walletPortfolio, + PRIME_ASSETS_MOBULA + ); + + const totalBalance = allPrimeAssets + .flatMap((assetGroup) => assetGroup.primeAssets) + .reduce((sum, asset) => sum + asset.usd_balance, 0); + + return limitDigitsNumber(totalBalance); + }, [walletPortfolio]); + + return ( +
+ prime-tokens-icon + + Prime Tokens Balance: $ + {primeAssetsBalance && primeAssetsBalance > 0 + ? primeAssetsBalance + : '0.00'} + + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + prime-tokens-question-icon + + {isHovered && ( +
+
+ Prime Tokens are used for trading and paying gas fees across all + chains. You’ll use them when buying assets and receive them when + selling. +
+
+ )} +
+
+ ); +}; + +export default PrimeTokensBalance; diff --git a/src/apps/pillarx-app/components/ReceiveModal.tsx b/src/apps/pillarx-app/components/ReceiveModal.tsx new file mode 100644 index 00000000..31b730f5 --- /dev/null +++ b/src/apps/pillarx-app/components/ReceiveModal.tsx @@ -0,0 +1,122 @@ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { useEffect, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { MdCheck } from 'react-icons/md'; + +// utils +import { CompatibleChains, getLogoForChainId } from '../../../utils/blockchain'; + +// reducer +import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; +import { setIsReceiveModalOpen } from '../reducer/WalletPortfolioSlice'; + +// images +import CopyIcon from '../images/token-market-data-copy.png'; + +// components +import TokenLogoMarketDataRow from './TokenMarketDataRow/TokenLogoMarketDataRow'; +import Body from './Typography/Body'; +import BodySmall from './Typography/BodySmall'; + +const ReceiveModal = () => { + const accountAddress = useWalletAddress(); + const dispatch = useAppDispatch(); + const isReceiveModalOpen = useAppSelector( + (state) => state.walletPortfolio.isReceiveModalOpen as boolean + ); + const [copied, setCopied] = useState(false); + + const handleOnCloseReceiveModal = () => { + dispatch(setIsReceiveModalOpen(false)); + }; + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(false), 3000); + return () => clearTimeout(timer); + } + + return undefined; + }, [copied]); + + if (!isReceiveModalOpen) return null; + + return ( +
+
+
+
+

Receive

+
handleOnCloseReceiveModal()} + > +

ESC

+
+
+ + Currently, PillarX Accounts support only the following ecosystems. + If you deposit tokens from other networks, you may not be able to + withdraw them. + + EVM Address +
+
+ + {!accountAddress + ? 'We were not able to retrieve your EVM address, please check your internet connection and reload the page.' + : accountAddress} + +
+
+ {copied ? ( + + ) : ( + setCopied(true)} + > + copy-evm-address e.stopPropagation()} + /> + + )} +
+
+ + Supported Chains +
+ {CompatibleChains.map((chain, index) => ( +
+ + + {chain.chainName} + +
+ ))} +
+
+
+
+ ); +}; + +export default ReceiveModal; diff --git a/src/apps/pillarx-app/components/TileContainer/TileContainer.tsx b/src/apps/pillarx-app/components/TileContainer/TileContainer.tsx index d80a119d..85bf3fd9 100644 --- a/src/apps/pillarx-app/components/TileContainer/TileContainer.tsx +++ b/src/apps/pillarx-app/components/TileContainer/TileContainer.tsx @@ -8,7 +8,7 @@ type TileContainerProps = { const TileContainer = ({ children, className, id }: TileContainerProps) => { return ( -
+
{children}
); diff --git a/src/apps/pillarx-app/components/TokenMarketDataRow/RightColumnTokenMarketDataRow.tsx b/src/apps/pillarx-app/components/TokenMarketDataRow/RightColumnTokenMarketDataRow.tsx index a5b7d7ae..5eab1d8f 100644 --- a/src/apps/pillarx-app/components/TokenMarketDataRow/RightColumnTokenMarketDataRow.tsx +++ b/src/apps/pillarx-app/components/TokenMarketDataRow/RightColumnTokenMarketDataRow.tsx @@ -3,6 +3,9 @@ import { TbTriangleFilled } from 'react-icons/tb'; // types import { TokensMarketDataRow } from '../../../../types/api'; +// utils +import { limitDigitsNumber } from '../../../../utils/number'; + // components import HighDecimalsFormatted from '../HighDecimalsFormatted/HighDecimalsFormatted'; import BodySmall from '../Typography/BodySmall'; @@ -21,7 +24,7 @@ const RightColumnTokenMarketDataRow = ({
{rightColumn?.line1?.price ? ( { const [isBrokenImage, setIsBrokenImage] = useState(false); const [isBrokenImageChain, setIsBrokenImageChain] = useState(false); return ( -
+
{tokenLogo && !isBrokenImage ? ( +
logo { + const walletPortfolio = useAppSelector( + (state) => + state.walletPortfolio.walletPortfolio as PortfolioData | undefined + ); + const isWalletPorfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPorfolioLoading as boolean + ); + const isWalletPortfolioErroring = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioErroring as boolean + ); + const isTopTokenUnrealizedPnLLoading = useAppSelector( + (state) => state.walletPortfolio.isTopTokenUnrealizedPnLLoading as boolean + ); + const isTopTokenUnrealizedPnLErroring = useAppSelector( + (state) => state.walletPortfolio.isTopTokenUnrealizedPnLErroring as boolean + ); + + const topTokens = useMemo(() => { + if (!walletPortfolio) return undefined; + + const topThreeAssets = getTopNonPrimeAssetsAcrossChains( + walletPortfolio, + PRIME_ASSETS_MOBULA + ); + + return topThreeAssets; + }, [walletPortfolio]); + + const isTopTokensEmpty = !topTokens || topTokens.length === 0; + + return ( +
+
+ Top Tokens +
+ {topTokens ? ( +
+ {/* Header Row */} +
+ Top Tokens +
+
+ Balance +
+
+ Token / $ / Balance +
+
+ Unrealized PnL/% +
+ + {topTokens?.map((token, index) => ( + +
+ +
+
+

+ {token.asset.symbol} +

+
+

+ {token.asset.name} +

+
+
+
+

+ ${limitDigitsNumber(token.price)} +

+

+ ${limitDigitsNumber(token.usdBalance)} +

+

+ {limitDigitsNumber(token.tokenBalance)} +

+
+
+
+
+

+ ${limitDigitsNumber(token.usdBalance)} +

+

+ {limitDigitsNumber(token.tokenBalance)} +

+
+
+ 0 && 'text-market_row_green'} ${token.unrealizedPnLUsd < 0 && 'text-percentage_red'} ${(!token.unrealizedPnLUsd || token.unrealizedPnLUsd === 0 || !token) && 'text-white'}`} + styleZeros="desktop:text-xs tablet:text-xs mobile:text-[10px]" + /> +
0 && 'text-market_row_green'} ${token.unrealizedPnLPercentage < 0 && 'text-percentage_red'} ${(!token.unrealizedPnLPercentage || token.unrealizedPnLPercentage === 0 || !token) && 'text-white'}`} + > + {!token || + !token.unrealizedPnLPercentage || + token.unrealizedPnLPercentage === 0 ? null : ( + 0 + ? '#5CFF93' + : '#FF366C' + } + style={{ + transform: + token.unrealizedPnLPercentage < 0 + ? 'rotate(180deg)' + : 'none', + }} + /> + )} +

+ {Math.abs(token.unrealizedPnLPercentage).toFixed(2)}% +

+
+
+
+ ))} +
+ ) : ( +
+ {isWalletPorfolioLoading || isTopTokenUnrealizedPnLLoading ? null : ( +
Top Tokens
+ )} +
+ {(isWalletPortfolioErroring || isTopTokenUnrealizedPnLErroring) && + (!isWalletPorfolioLoading || !isTopTokenUnrealizedPnLLoading) ? ( +
+ {isWalletPortfolioErroring && ( + + Failed to load wallet portfolio. + + )} + {isTopTokenUnrealizedPnLErroring && ( + + Failed to load unrealized PnL. + + )} + + Please check your internet connection and reload the page. + +
+ ) : null} + + {/* No tokens fallback */} + {!isWalletPorfolioLoading && + !isTopTokenUnrealizedPnLLoading && + !isWalletPortfolioErroring && + !isTopTokenUnrealizedPnLErroring && + isTopTokensEmpty && ( + + No tokens yet + + )} +
+
+ )} +
+ ); +}; + +export default TopTokens; diff --git a/src/apps/pillarx-app/components/WalletConnectDropdown/WalletConnectDropdown.tsx b/src/apps/pillarx-app/components/WalletConnectDropdown/WalletConnectDropdown.tsx index af60d16c..40e858b8 100644 --- a/src/apps/pillarx-app/components/WalletConnectDropdown/WalletConnectDropdown.tsx +++ b/src/apps/pillarx-app/components/WalletConnectDropdown/WalletConnectDropdown.tsx @@ -4,6 +4,7 @@ import { CircularProgress } from '@mui/material'; import { usePrivy } from '@privy-io/react-auth'; import { useEffect, useRef, useState } from 'react'; +import { RiArrowDownSLine } from 'react-icons/ri'; // services import { useWalletConnect } from '../../../../services/walletConnect'; @@ -12,7 +13,6 @@ import { useWalletConnect } from '../../../../services/walletConnect'; import { isAddressInSessionViaPrivy } from '../../../../utils/walletConnect'; // images -import ArrowDown from '../../images/arrow-down.svg'; import SettingIcon from '../../images/setting-wheel.svg'; import Ticked from '../../images/tick-square-ticked.svg'; import Unticked from '../../images/tick-square-unticked.svg'; @@ -21,7 +21,6 @@ import WalletConnectLogo from '../../images/wallet-connect-logo.svg'; // components import RandomAvatar from '../RandomAvatar/RandomAvatar'; import SwitchToggle from '../SwitchToggle/SwitchToggle'; -import Body from '../Typography/Body'; import BodySmall from '../Typography/BodySmall'; const WalletConnectDropdown = () => { @@ -335,20 +334,20 @@ const WalletConnectDropdown = () => { return (
-
+
wallet-connect-logo - WalletConnect + WalletConnect {numberActiveSessions ? (

@@ -356,17 +355,15 @@ const WalletConnectDropdown = () => {

) : null} -
-
- arrow-down
{isDropdownOpen && ( -
+
)} diff --git a/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx b/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx new file mode 100644 index 00000000..5c020560 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx @@ -0,0 +1,165 @@ +import { useCallback, useMemo } from 'react'; +import { TbTriangleFilled } from 'react-icons/tb'; + +// types +import { PortfolioData, WalletHistory } from '../../../../types/api'; + +// utils +import { limitDigitsNumber } from '../../../../utils/number'; + +// reducer +import { useAppDispatch, useAppSelector } from '../../hooks/useReducerHooks'; +import { setIsRefreshAll } from '../../reducer/WalletPortfolioSlice'; + +// images +import RefreshIcon from '../../images/refresh-button.png'; +import WalletPortfolioIcon from '../../images/wallet-portfolio-icon.png'; + +// components +import SkeletonLoader from '../../../../components/SkeletonLoader'; +import Body from '../Typography/Body'; +import BodySmall from '../Typography/BodySmall'; + +const WalletPortfolioBalance = () => { + const dispatch = useAppDispatch(); + const walletPortfolio = useAppSelector( + (state) => + state.walletPortfolio.walletPortfolio as PortfolioData | undefined + ); + const topTokenUnrealizedPnL = useAppSelector( + (state) => + state.walletPortfolio.topTokenUnrealizedPnL as WalletHistory | undefined + ); + const isWalletPorfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPorfolioLoading as boolean + ); + const isWalletPorfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPorfolioWithPnlLoading as boolean + ); + const isWalletHistoryGraphLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletHistoryGraphLoading as boolean + ); + const isTopTokenUnrealizedPnLLoading = useAppSelector( + (state) => state.walletPortfolio.isTopTokenUnrealizedPnLLoading as boolean + ); + + const isAnyDataFetching = + isWalletPorfolioLoading || + isWalletPorfolioWithPnlLoading || + isWalletHistoryGraphLoading || + isTopTokenUnrealizedPnLLoading; + + const balanceChange = useMemo(() => { + if (!topTokenUnrealizedPnL) return undefined; + + const sorted = [...topTokenUnrealizedPnL.balance_history].sort( + (a, b) => a[0] - b[0] + ); + const first = sorted[0][1]; + const last = sorted[sorted.length - 1][1]; + const usdValue = last - first; + const percentageValue = first !== 0 ? (usdValue / first) * 100 : 0; + + return { usdValue, percentageValue }; + }, [topTokenUnrealizedPnL]); + + const getUsdChangeText = useCallback(() => { + if ( + !balanceChange || + !balanceChange.usdValue || + balanceChange.usdValue === 0 + ) + return '$0.00'; + const absValue = Math.abs(balanceChange.usdValue); + + return balanceChange.usdValue > 0 + ? `+$${limitDigitsNumber(absValue)}` + : `-$${limitDigitsNumber(absValue)}`; + }, [balanceChange]); + + return ( +
+
+
+ wallet-portfolio-icon + My portfolio +
+ {isWalletPorfolioLoading || !walletPortfolio ? ( + + ) : ( +
+

0 ? 'text-white' : 'text-white text-opacity-50'}`} + > + $ + {walletPortfolio.total_wallet_balance > 0 + ? limitDigitsNumber(walletPortfolio?.total_wallet_balance) + : '0.00'} +

+
+ {!balanceChange || !balanceChange?.usdValue ? null : ( + 0 && 'text-market_row_green'} ${balanceChange.usdValue < 0 && 'text-percentage_red'} ${getUsdChangeText() === '$0.00' && 'text-white text-opacity-50'} ${balanceChange.usdValue === 0 && 'text-white text-opacity-50 bg-white/[.1]'}`} + > + {getUsdChangeText()} + + )} + {!balanceChange || !balanceChange?.percentageValue ? null : ( +
0 && 'text-market_row_green bg-market_row_green/[.1]'} ${balanceChange.percentageValue < 0 && 'text-percentage_red bg-percentage_red/[.1]'} ${balanceChange.percentageValue === 0 && 'text-white text-opacity-50 bg-white/[.1]'}`} + > + {balanceChange.percentageValue === 0 ? null : ( + 0 + ? '#5CFF93' + : '#FF366C' + } + style={{ + transform: + balanceChange.percentageValue < 0 + ? 'rotate(180deg)' + : 'none', + }} + /> + )} + + + {balanceChange.percentageValue !== 0 + ? Math.abs(balanceChange.percentageValue).toFixed(2) + : '0.00'} + % + +
+ )} + {!balanceChange || !balanceChange.percentageValue ? null : ( + + 24h + + )} +
+
+ )} +
+
{ + if (!isAnyDataFetching) { + dispatch(setIsRefreshAll(true)); + } + }} + > + refresh-button +
+
+ ); +}; + +export default WalletPortfolioBalance; diff --git a/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx b/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx new file mode 100644 index 00000000..34fb1182 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx @@ -0,0 +1,31 @@ +import { RiArrowDownLine } from 'react-icons/ri'; + +// reducer +import { useAppDispatch } from '../../hooks/useReducerHooks'; +import { setIsReceiveModalOpen } from '../../reducer/WalletPortfolioSlice'; + +// components +import ReceiveModal from '../ReceiveModal'; +import BodySmall from '../Typography/BodySmall'; +import WalletConnectDropdown from '../WalletConnectDropdown/WalletConnectDropdown'; + +const WalletPortfolioButtons = () => { + const dispatch = useAppDispatch(); + return ( +
+ +
dispatch(setIsReceiveModalOpen(true))} + > +
+ Receive + +
+
+ +
+ ); +}; + +export default WalletPortfolioButtons; diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx new file mode 100644 index 00000000..3101b0ee --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx @@ -0,0 +1,474 @@ +import { + CategoryScale, + Chart, + ChartArea, + ChartData, + Chart as ChartJS, + ChartOptions, + Filler, + Legend, + LineElement, + LinearScale, + PointElement, + ScriptableContext, + TimeScale, + Tooltip, +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { format, parseISO } from 'date-fns'; +import { useCallback, useState } from 'react'; +import { Line } from 'react-chartjs-2'; + +// types +import { PnLEntry, PortfolioData, WalletHistory } from '../../../../types/api'; + +// utils +import { convertDateToUnixTimestamp } from '../../../../utils/common'; +import { limitDigitsNumber } from '../../../../utils/number'; +import { PeriodFilterBalance, PeriodFilterPnl } from '../../utils/portfolio'; + +// reducer +import { useAppSelector } from '../../hooks/useReducerHooks'; + +// components +import Body from '../Typography/Body'; + +const crosshairPlugin = { + id: 'crosshairPlugin', + afterDraw: (chart: Chart) => { + if (!chart.tooltip?.getActiveElements()?.length) return; + + const { ctx } = chart; + const { chartArea: area } = chart; + const activePoint = chart.tooltip.getActiveElements()[0]; + const { x, y } = chart.getDatasetMeta(activePoint.datasetIndex).data[ + activePoint.index + ]; + + ctx.save(); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(x, area.top); + ctx.lineTo(x, area.bottom); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(200, 200, 200, 0.4)'; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.closePath(); + + // Horizontal line + ctx.beginPath(); + ctx.moveTo(area.left, y); + ctx.lineTo(area.right, y); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(200, 200, 200, 0.4)'; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.closePath(); + + ctx.restore(); + }, +}; + +ChartJS.register( + LineElement, + PointElement, + CategoryScale, + LinearScale, + TimeScale, + Filler, + Tooltip, + Legend, + crosshairPlugin +); + +const BalancePnlGraph = () => { + const [hoverValue, setHoverValue] = useState(); + + const walletHistoryGraph = useAppSelector( + (state) => + state.walletPortfolio.walletHistoryGraph as WalletHistory | undefined + ); + const walletPortfolioWithPnl = useAppSelector( + (state) => + state.walletPortfolio.walletPortfolioWithPnl as PortfolioData | undefined + ); + const isWalletPorfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPorfolioWithPnlLoading as boolean + ); + const isWalletPortfolioWithPnlErroring = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioWithPnlErroring as boolean + ); + const isWalletHistoryGraphLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletHistoryGraphLoading as boolean + ); + const isWalletHistoryGraphErroring = useAppSelector( + (state) => state.walletPortfolio.isWalletHistoryGraphErroring as boolean + ); + const periodFilter = useAppSelector( + (state) => state.walletPortfolio.periodFilter as PeriodFilterBalance + ); + const periodFilterPnl = useAppSelector( + (state) => state.walletPortfolio.periodFilterPnl as PeriodFilterPnl + ); + const selectedBalanceOrPnl = useAppSelector( + (state) => state.walletPortfolio.selectedBalanceOrPnl as 'balance' | 'pnl' + ); + + const isBalanceGraph = selectedBalanceOrPnl === 'balance'; + + // This gets the right color depending on the price value threshold, a callback is used + // to make sure this only gets called if tokenDataInfo.price changes, otherwise the graph crashes + const getGradient = useCallback( + (ctx: CanvasRenderingContext2D, chartArea: ChartArea) => { + const gradientBg = ctx.createLinearGradient( + 0, + chartArea.top, + 0, + chartArea.bottom + ); + + // this changes the color of the line depending on a value threshold + gradientBg.addColorStop(0, '#8A77FF'); + gradientBg.addColorStop(1, '#1E1D24'); + + return gradientBg; + }, + [] + ); + + // This gets the right graph time scale (x) depending on the periodFilter selected. A callback is used + // to make sure this only gets called if tokenDataGraph?.result.data changes, otherwise the graph crashes + const graphXScale = useCallback(() => { + if (isBalanceGraph) { + if (walletHistoryGraph?.balance_history.length) { + if (periodFilter === PeriodFilterBalance.HOUR) { + return 'minute'; + } + + if (periodFilter === PeriodFilterBalance.DAY) { + return 'hour'; + } + + if (periodFilter === PeriodFilterBalance.WEEK) { + return 'day'; + } + + if (periodFilter === PeriodFilterBalance.MONTH) { + return 'day'; + } + + if (periodFilter === PeriodFilterBalance.HALF_YEAR) { + return 'month'; + } + + return 'hour'; + } + + return 'hour'; + } + + if (walletPortfolioWithPnl?.total_pnl_history) { + if (periodFilterPnl === PeriodFilterPnl.DAY) { + return 'hour'; + } + + if (periodFilterPnl === PeriodFilterPnl.WEEK) { + return 'day'; + } + + if (periodFilterPnl === PeriodFilterPnl.MONTH) { + return 'day'; + } + + if (periodFilterPnl === PeriodFilterPnl.YEAR) { + return 'month'; + } + + return 'hour'; + } + + return 'hour'; + }, [ + isBalanceGraph, + periodFilter, + periodFilterPnl, + walletHistoryGraph?.balance_history.length, + walletPortfolioWithPnl?.total_pnl_history, + ]); + + const getPnlDataLabels = () => { + const pnlHistory = walletPortfolioWithPnl?.pnl_history; + + if (!pnlHistory) return []; + + let entries: PnLEntry[] = []; + + switch (periodFilterPnl) { + case PeriodFilterPnl.DAY: + entries = pnlHistory['24h']; + break; + case PeriodFilterPnl.WEEK: + entries = pnlHistory['7d']; + break; + case PeriodFilterPnl.MONTH: + entries = pnlHistory['30d']; + break; + case PeriodFilterPnl.YEAR: + entries = pnlHistory['1y']; + break; + default: + entries = pnlHistory['24h']; + } + + return entries.map( + (entry) => convertDateToUnixTimestamp(parseISO(entry[0])) * 1000 + ); + }; + + const getPnlDataSet = () => { + const pnlHistory = walletPortfolioWithPnl?.pnl_history; + + if (!pnlHistory) return []; + + switch (periodFilterPnl) { + case PeriodFilterPnl.DAY: + return pnlHistory['24h'].map((entry) => entry[1].unrealized); + case PeriodFilterPnl.WEEK: + return pnlHistory['7d'].map((entry) => entry[1].unrealized); + case PeriodFilterPnl.MONTH: + return pnlHistory['30d'].map((entry) => entry[1].unrealized); + case PeriodFilterPnl.YEAR: + return pnlHistory['1y'].map((entry) => entry[1].unrealized); + default: + return []; + } + }; + + const getGraphStepSize = () => { + if (isBalanceGraph) { + switch (periodFilter) { + case PeriodFilterBalance.HOUR: + return 10; + case PeriodFilterBalance.DAY: + case PeriodFilterBalance.MONTH: + return 3; + case PeriodFilterBalance.WEEK: + case PeriodFilterBalance.HALF_YEAR: + return 1; + default: + return 3; + } + } + + // PnL case + switch (periodFilterPnl) { + case PeriodFilterPnl.DAY: + case PeriodFilterPnl.MONTH: + case PeriodFilterPnl.YEAR: + return 3; + case PeriodFilterPnl.WEEK: + return 1; + default: + return 3; + } + }; + + const createGradient = ( + context: ScriptableContext<'line'> + ): CanvasGradient | undefined => { + const { chart } = context; + const { ctx, chartArea } = chart; + + if (!chartArea) return undefined; + + return getGradient(ctx, chartArea); + }; + + const flatLineData: ChartData<'line'> = { + labels: [Date.now() - 1000 * 60 * 60, Date.now()], // 1 hour apart + datasets: [ + { + label: 'Flat line', + data: [ + { x: Date.now() - 1000 * 60 * 60, y: 1 }, + { x: Date.now(), y: 1 }, + ], + borderColor: '#B8B4FF', + borderWidth: 2, + backgroundColor: (ctx: ScriptableContext<'line'>) => + createGradient(ctx), + fill: true, + tension: 0.4, + pointRadius: 0, + }, + ], + }; + + const hasBalanceData = + isBalanceGraph && walletHistoryGraph?.balance_history.length; + const hasPnlData = !isBalanceGraph && getPnlDataSet().length > 0; + + // This gives us the right dataset for the graph + const data: ChartData<'line'> = + hasBalanceData || hasPnlData + ? { + labels: isBalanceGraph + ? walletHistoryGraph?.balance_history.map((x) => x[0]) + : getPnlDataLabels(), + datasets: [ + { + label: 'Token price', + data: isBalanceGraph + ? walletHistoryGraph?.balance_history.map( + (price) => price[1] + ) || [] + : getPnlDataSet(), + borderColor: '#B8B4FF', + borderWidth: 2, + backgroundColor: (ctx: ScriptableContext<'line'>) => + createGradient(ctx), + fill: true, + tension: 0.4, + pointRadius: 0, + }, + ], + } + : flatLineData; + + // The options used to customize the UI of the graph + const options: ChartOptions<'line'> = { + onHover: (event, chartElements) => { + if (chartElements.length > 0 && (hasBalanceData || hasPnlData)) { + const { datasetIndex, index } = chartElements[0]; + const dataset = data.datasets[datasetIndex]; + const value = dataset.data[index] as number; + setHoverValue(value); + } else { + setHoverValue(undefined); + } + }, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'nearest', + axis: 'xy', + intersect: false, + }, + plugins: { + title: { + display: true, + text: 'Token price history', + }, + filler: { + drawTime: 'beforeDatasetDraw', + propagate: true, + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + title: (tooltipItems) => { + const timestampValue = tooltipItems[0].parsed.x; + return format(new Date(timestampValue), 'HH:mm, MMM d'); + }, + label: () => '', + }, + backgroundColor: 'transparent', + titleFont: { + size: 10, + weight: 'normal', + }, + titleColor: 'rgba(255, 255, 255, 0.7)', + }, + }, + scales: { + x: { + offset: false, + type: 'time', + time: { + unit: graphXScale() || 'day', + displayFormats: { + minute: 'HH:mm', + hour: 'HH:mm', + day: 'dd MMM', + month: 'MMM y', + }, + }, + border: { + width: 0, + }, + grid: { + drawOnChartArea: false, + drawTicks: false, + }, + ticks: { + z: 1, + padding: -20, + font: { + weight: 400, + size: 10, + }, + stepSize: getGraphStepSize(), + color: '#DDDDDD', + }, + }, + y: { display: false }, + }, + }; + + if (selectedBalanceOrPnl === 'balance' && isWalletHistoryGraphLoading) { + return ( +
+ ); + } + + if (selectedBalanceOrPnl === 'pnl' && isWalletPorfolioWithPnlLoading) { +
; + } + + if (selectedBalanceOrPnl === 'balance' && isWalletHistoryGraphErroring) { + return ( +
+ + Failed to load your wallet balance history. + +
+ ); + } + + if (selectedBalanceOrPnl === 'pnl' && isWalletPortfolioWithPnlErroring) { + return ( +
+ + Failed to load your wallet PnL history. + +
+ ); + } + + return ( + <> + + + Total Value:{' '} + + + {hoverValue ? `$${limitDigitsNumber(hoverValue)}` : '$0.00'} + + +
+ +
+ + ); +}; + +export default BalancePnlGraph; diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraph.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraph.tsx new file mode 100644 index 00000000..6cbe45a5 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraph.tsx @@ -0,0 +1,155 @@ +import { sub } from 'date-fns'; +import { useEffect } from 'react'; + +// utils +import { convertDateToUnixTimestamp } from '../../../../utils/common'; +import { PeriodFilterBalance, PeriodFilterPnl } from '../../utils/portfolio'; + +// reducer +import { useAppDispatch, useAppSelector } from '../../hooks/useReducerHooks'; +import { + setPeriodFilter, + setPeriodFilterPnl, + setPriceGraphPeriod, + setSelectedBalanceOrPnl, +} from '../../reducer/WalletPortfolioSlice'; + +// components +import BodySmall from '../Typography/BodySmall'; +import BalancePnlGraph from './BalancePnlGraph'; +import WalletPortfolioGraphButton from './WalletPortfolioGraphButton'; + +const WalletPortfolioGraph = () => { + const dispatch = useAppDispatch(); + const periodFilter = useAppSelector( + (state) => state.walletPortfolio.periodFilter as PeriodFilterBalance + ); + const periodFilterPnl = useAppSelector( + (state) => state.walletPortfolio.periodFilterPnl as PeriodFilterPnl + ); + const selectedBalanceOrPnl = useAppSelector( + (state) => state.walletPortfolio.selectedBalanceOrPnl as 'balance' | 'pnl' + ); + + const timeFilter = + selectedBalanceOrPnl === 'balance' + ? [ + { time: PeriodFilterBalance.HOUR, text: '1h' }, + { time: PeriodFilterBalance.DAY, text: '24h' }, + { time: PeriodFilterBalance.WEEK, text: '1w' }, + { time: PeriodFilterBalance.MONTH, text: '1mo' }, + { time: PeriodFilterBalance.HALF_YEAR, text: '6mo' }, + ] + : [ + { time: PeriodFilterPnl.DAY, text: '24h' }, + { time: PeriodFilterPnl.WEEK, text: '1w' }, + { time: PeriodFilterPnl.MONTH, text: '1mo' }, + { time: PeriodFilterPnl.YEAR, text: '1y' }, + ]; + + // The handleClickTimePeriod makes sure we select the right "from" Unix timestamp to today's Unix timestamp for the price history graph + const handleClickTimePeriod = ( + filter: PeriodFilterBalance | PeriodFilterPnl + ) => { + if (selectedBalanceOrPnl === 'balance') { + dispatch(setPeriodFilter(filter as PeriodFilterBalance)); + const now = new Date(); + let from; + switch (filter) { + case PeriodFilterBalance.HOUR: + from = sub(now, { hours: 1 }); + break; + case PeriodFilterBalance.DAY: + from = sub(now, { days: 1 }); + break; + case PeriodFilterBalance.WEEK: + from = sub(now, { weeks: 1 }); + break; + case PeriodFilterBalance.MONTH: + from = sub(now, { months: 1 }); + break; + case PeriodFilterBalance.HALF_YEAR: + from = sub(now, { months: 6 }); + break; + default: + from = sub(now, { days: 1 }); + break; + } + dispatch( + setPriceGraphPeriod({ + from: convertDateToUnixTimestamp(from), + to: undefined, + }) + ); + } + + if (selectedBalanceOrPnl === 'pnl') { + dispatch(setPeriodFilterPnl(filter as PeriodFilterPnl)); + } + }; + + useEffect(() => { + handleClickTimePeriod(PeriodFilterBalance.DAY); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+
+ dispatch(setSelectedBalanceOrPnl('balance'))} + text="Balance" + isActive={selectedBalanceOrPnl === 'balance'} + /> + dispatch(setSelectedBalanceOrPnl('pnl'))} + text="PnL" + isActive={selectedBalanceOrPnl === 'pnl'} + /> +
+
+ + +
+
+ {timeFilter.map((filter, index) => ( + handleClickTimePeriod(filter.time)} + /> + ))} +
+
+ +
+ ); +}; + +export default WalletPortfolioGraph; diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx new file mode 100644 index 00000000..9226a9fc --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx @@ -0,0 +1,25 @@ +// components +import BodySmall from '../Typography/BodySmall'; + +type WalletPortfolioGraphButtonProps = { + isActive: boolean; + text: string; + onClick: () => void; +}; + +const WalletPortfolioGraphButton = ({ + isActive, + text, + onClick, +}: WalletPortfolioGraphButtonProps) => { + return ( +
+ {text} +
+ ); +}; + +export default WalletPortfolioGraphButton; diff --git a/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx b/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx new file mode 100644 index 00000000..a887ad25 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx @@ -0,0 +1,231 @@ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { sub } from 'date-fns'; +import { useEffect, useMemo } from 'react'; + +// services +import { useGetWalletHistoryQuery } from '../../../../services/pillarXApiWalletHistory'; +import { useGetWalletPortfolioQuery } from '../../../../services/pillarXApiWalletPortfolio'; + +// types +import { TokenPriceGraphPeriod } from '../../../../types/api'; + +// utils +import { convertDateToUnixTimestamp } from '../../../../utils/common'; +import { + PeriodFilterBalance, + getGraphResolutionBalance, +} from '../../utils/portfolio'; + +// hooks +import { useDataFetchingState } from '../../hooks/useDataFetchingState'; + +// reducer +import { useAppDispatch, useAppSelector } from '../../hooks/useReducerHooks'; +import { + setIsRefreshAll, + setIsTopTokenUnrealizedPnLErroring, + setIsTopTokenUnrealizedPnLLoading, + setIsWalletHistoryGraphErroring, + setIsWalletHistoryGraphLoading, + setIsWalletPorfolioLoading, + setIsWalletPortfolioErroring, + setIsWalletPortfolioWithPnlErroring, + setIsWalletPortfolioWithPnlLoading, + setTopTokenUnrealizedPnL, + setWalletHistoryGraph, + setWalletPortfolio, + setWalletPortfolioWithPnl, +} from '../../reducer/WalletPortfolioSlice'; + +// components +import PrimeTokensBalance from '../PrimeTokensBalance/PrimeTokensBalance'; +import TileContainer from '../TileContainer/TileContainer'; +import TopTokens from '../TopTokens/TopTokens'; +import WalletPortfolioBalance from '../WalletPortfolioBalance/WalletPortfolioBalance'; +import WalletPortfolioButtons from '../WalletPortfolioButtons/WalletPortfolioButtons'; +import WalletPortfolioGraph from '../WalletPortfolioGraph/WalletPortfolioGraph'; + +const WalletPortfolioTile = () => { + const accountAddress = useWalletAddress(); + + const dispatch = useAppDispatch(); + + const priceGraphPeriod = useAppSelector( + (state) => state.walletPortfolio.priceGraphPeriod as TokenPriceGraphPeriod + ); + const periodFilter = useAppSelector( + (state) => state.walletPortfolio.periodFilter as PeriodFilterBalance + ); + const selectedBalanceOrPnl = useAppSelector( + (state) => state.walletPortfolio.selectedBalanceOrPnl as 'balance' | 'pnl' + ); + const isRefreshAll = useAppSelector( + (state) => state.walletPortfolio.isRefreshAll as boolean + ); + + // Query parameters + const topTokenUnrealizedPnLQueryArgs = useMemo( + () => ({ + wallet: accountAddress || '', + period: '1h', + from: convertDateToUnixTimestamp(sub(new Date(), { days: 1 })), + }), + [accountAddress] + ); + + const walletHistoryDataQueryArgs = useMemo( + () => ({ + wallet: accountAddress || '', + period: getGraphResolutionBalance(periodFilter), + from: priceGraphPeriod.from, + }), + [accountAddress, periodFilter, priceGraphPeriod.from] + ); + + const walletPortfolioWithPnlArgs = useMemo( + () => ({ + wallet: accountAddress || '', + isPnl: true, + }), + [accountAddress] + ); + + const shouldFetchPnl = !!accountAddress && selectedBalanceOrPnl === 'pnl'; + + // API Queries + const { + data: walletPortfolioData, + isLoading: isWalletPortfolioDataLoading, + isFetching: isWalletPortfolioDataFetching, + isSuccess: isWalletPortfolioDataSuccess, + error: walletPortfolioDataError, + refetch: refetchWalletPortfolioData, + } = useGetWalletPortfolioQuery( + { wallet: accountAddress || '', isPnl: false }, + { skip: !accountAddress } + ); + + const { + data: walletPortfolioWithPnlData, + isLoading: isWalletPortfolioDataWithPnlLoading, + isFetching: isWalletPortfolioDataWithPnlFetching, + isSuccess: isWalletPortfolioDataWithPnlSuccess, + error: walletPortfolioDataWithPnlError, + refetch: refetchWalletPortfolioWithPnlData, + } = useGetWalletPortfolioQuery(walletPortfolioWithPnlArgs, { + skip: !shouldFetchPnl, + }); + + const { + data: walletHistoryData, + isLoading: isWalletHistoryDataLoading, + isFetching: isWalletHistoryDataFetching, + isSuccess: isWalletHistoryDataSuccess, + error: walletHistoryDataError, + refetch: refetchWalletHistoryData, + } = useGetWalletHistoryQuery(walletHistoryDataQueryArgs, { + skip: !accountAddress, + }); + + const { + data: topTokenUnrealizedPnLData, + isLoading: isTopTokenUnrealizedPnLDataLoading, + isFetching: isTopTokenUnrealizedPnLDataFetching, + isSuccess: isTopTokenUnrealizedPnLDataSuccess, + error: topTokenUnrealizedPnLDataError, + refetch: refetchTopTokenUnrealizedPnLData, + } = useGetWalletHistoryQuery(topTokenUnrealizedPnLQueryArgs, { + skip: !accountAddress, + }); + + useDataFetchingState( + walletPortfolioData?.result?.data, + isWalletPortfolioDataLoading, + isWalletPortfolioDataFetching, + isWalletPortfolioDataSuccess, + walletPortfolioDataError, + setWalletPortfolio, + setIsWalletPorfolioLoading, + setIsWalletPortfolioErroring + ); + + useDataFetchingState( + walletPortfolioWithPnlData?.result?.data, + isWalletPortfolioDataWithPnlLoading, + isWalletPortfolioDataWithPnlFetching, + isWalletPortfolioDataWithPnlSuccess, + walletPortfolioDataWithPnlError, + setWalletPortfolioWithPnl, + setIsWalletPortfolioWithPnlLoading, + setIsWalletPortfolioWithPnlErroring + ); + + useDataFetchingState( + walletHistoryData?.result?.data, + isWalletHistoryDataLoading, + isWalletHistoryDataFetching, + isWalletHistoryDataSuccess, + walletHistoryDataError, + setWalletHistoryGraph, + setIsWalletHistoryGraphLoading, + setIsWalletHistoryGraphErroring + ); + + useDataFetchingState( + topTokenUnrealizedPnLData?.result?.data, + isTopTokenUnrealizedPnLDataLoading, + isTopTokenUnrealizedPnLDataFetching, + isTopTokenUnrealizedPnLDataSuccess, + topTokenUnrealizedPnLDataError, + setTopTokenUnrealizedPnL, + setIsTopTokenUnrealizedPnLLoading, + setIsTopTokenUnrealizedPnLErroring + ); + + // eslint-disable-next-line consistent-return + useEffect(() => { + if (isRefreshAll) { + refetchWalletPortfolioData(); + refetchWalletHistoryData(); + refetchTopTokenUnrealizedPnLData(); + + if (selectedBalanceOrPnl === 'pnl') { + refetchWalletPortfolioWithPnlData(); + } + + const timeout = setTimeout(() => { + dispatch(setIsRefreshAll(false)); + }, 5000); + + return () => clearTimeout(timeout); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRefreshAll]); + + return ( + +
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ ); +}; + +export default WalletPortfolioTile; diff --git a/src/apps/pillarx-app/hooks/useDataFetchingState.ts b/src/apps/pillarx-app/hooks/useDataFetchingState.ts new file mode 100644 index 00000000..8dd7380e --- /dev/null +++ b/src/apps/pillarx-app/hooks/useDataFetchingState.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect } from 'react'; + +// reducer +import { useAppDispatch } from './useReducerHooks'; + +export const useDataFetchingState = ( + data: T | undefined, + isLoading: boolean, + isFetching: boolean, + isSuccess: boolean, + error: any, + setData: (data: T | undefined) => any, + setIsLoading: (isLoading: boolean) => any, + setIsErroring?: (isErroring: boolean) => any +) => { + const dispatch = useAppDispatch(); + + // Update loading state + useEffect(() => { + dispatch(setIsLoading(isLoading || isFetching)); + }, [dispatch, isLoading, isFetching, setIsLoading]); + + // Update data and error states + useEffect(() => { + const isError = !isLoading && !isFetching && !!error; + + if (data && isSuccess) { + dispatch(setData(data)); + if (setIsErroring) { + dispatch(setIsErroring(false)); + } + } else if (isError) { + dispatch(setData(undefined)); + if (setIsErroring) { + dispatch(setIsErroring(true)); + } + } + }, [ + dispatch, + data, + isSuccess, + error, + isLoading, + isFetching, + setData, + setIsErroring, + ]); +}; diff --git a/src/apps/pillarx-app/hooks/useReducerHooks.tsx b/src/apps/pillarx-app/hooks/useReducerHooks.tsx new file mode 100644 index 00000000..ec4857d0 --- /dev/null +++ b/src/apps/pillarx-app/hooks/useReducerHooks.tsx @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from '../../../store'; + +// To use throughout the app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/apps/pillarx-app/images/prime-tokens-icon.png b/src/apps/pillarx-app/images/prime-tokens-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3087af287f9e22f5e22b778b7d39d838ee601bf9 GIT binary patch literal 2121 zcmV-P2)6f$P)@~0drDELIAGL9O(c600d`2O+f$vv5yP_xIp8V$6CrcoEh6|gli zV9H8^l#Q$!F}R@{hCpD$5F)iLZRcIT^X5%?^WK}z`?)inFDWzc&HK6cyXTzyopUb= z+lceW^)8*kE3mOk16iKx#2c7CU0 z_ALm;p-#I4N1Qf>AT4MOB~Hu! z^Qj0Bb;k$v!X#`AC>yg%<0rQI5DcP?nnZ+|_gpFhN*eoN5DZwol*&gokL9~zYhWHy z)#k(m$mlmf=v2#Pjc!ZfWf+9yB3>(KM?#n!eU8E&O6E@x;!e7Rujvjq8N}u&bBizl z*9soW_e472?3m86(T8CI^m8`ilKv^n3OzHwTm_XFdm#>vr@R>3c zbfqRCpW8`5)*J95Lt_XAz}Vst89N5J0i+Wt(FXL{IuPNDH1&%dodYAXPWn1oO|dNA6W)V>;VGKjp)-fv!h`buXN)%41@2wN+M1P z5&T#sG@jO-I%Wk$w>cAy6FPbV|oZajc@6o=9g&+=^>*jiOnAQBLjn56+Y-DD`)^Hgr2>K0UD?M7qGJd z3!qi}i;P`V5ZuDY zsT#8QNB;JeC`J>3PoO*&EVDp8y&viZOE2NJAuHHKSec8XzG$lB&hyHnf2VRR1mx=9 z71Rh43@zJCVdBMWsxw5#A`Et3kYPc^d#srDMyfG%FfTaf%L@eOM)iPgc;}aB0X}oZ zbm#fp#gmh2-ghn2f!I&-n1*&hJ=n6V)~i?f)*Q@0GdgESS*l@NOXnY(87=b zV&I$m;s+H-=Y@bm#ODZ$7yMNsflMhK+H5>92}<4@wRVijG+01%zBk{mhIPegyrFs2(IwFrc>-OkO0R@PC-)IxFhbzW?7u& z?!40BQ(WYSY2;5%tBEMr%BIDYE@a!eL~YA*m~#HROtwZNWe%76U9n{2QP&EJVWK8c zn=4-oSV08^wBf^%Gb+(u9+w!W+{TWsnd)dQR4t6qJcm{CSWpFy)3>;(#Z*%iL=*H$7Ubf2K?18gfVljYEu zmptGTtQDsku?I(IiB`cOz437!5|vdVq`oSBDQGUbbioSC+ew zM-xzYtE{^%Ry4vOng9mOiP1@wN0U&OfC`-T!?IAyp#k9ZJI{}aW(UZ~1PEMf7;>#4 z2Qw(J6%!+q>LRuQu?VQZ36RVldGjE1fPTb;GYgdK5EI)yB_<-kiwYU64)3G#>vNJE z&&CSrEdBZ^&)uTtzsH5u`K4~mb6w#nS^)n8focXi(=vw;00000NkvXXu0mjfKaJdK literal 0 HcmV?d00001 diff --git a/src/apps/pillarx-app/images/prime-tokens-question-icon.png b/src/apps/pillarx-app/images/prime-tokens-question-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..72d049ee4699a93196380289d644d548aed8adc5 GIT binary patch literal 978 zcmV;@11@~0drDELIAGL9O(c600d`2O+f$vv5yP z7SLZ+*DmW&em&DO-8(}>0hrBZHNNb#)!BB;70)8kY-()6HfDQ>qUeDUsA;7g0D?e#qVvHf8uM#~y46=g!EL7Qc1NEJG;CE_iGPSo-qny}dx@t|Qe zEwJE#nXoO|KAJ{biiHs3MsFy|roOb8L}aoDcI6FvHU(J;!VxX;F9Gme3RNS(}Y5k#_Yd1L`BJG!QSBT=c#s2S{pF zI)Kc+th~V7ah>>uz=0W6^S+BLnm=zNn_0qaF z08k@~0drDELIAGL9O(c600d`2O+f$vv5yP7{?iP^7;IfAbCuvhfJhnOZJT#_;)1i4bshf>Np+uk?l zy0=~0Lj>&sjx#b<$kS`;<4>?qT8$DG(TruwY1f^fp(fKb0XdG-T`07jCP`=1!){^> zM7WaZylvYv&9Y1l3y6giNiyp8bIWeK*r>j*xX#3qdKX}0wFM#`P^H|98M7v1B7eY_AB*Rqna&H(drc5R(D50!@eoBfCdMSzNI#A5EFN%QmKQ^ z#^&19Fgu5efd>0WX4RhK#3TnvbBjy!6G6LKK|5&6!{SLE>YP9JI==Y2FlBkpst-Ct zOi~$7rPAHXoxN9WhpGZXwx!baH;G9)lPa*@sFs~ojDb++Ddcmr#Fb^HNJdqyCo0v6 zD#k#CeEu4-c$ojwjlyxI7e3>{1_q9v5+qL%!|cYXYL z;$vc%8d1X=hpl}6Q*PB@>H?X&C4^uBF?AtiVcZgujuFGugAk4-1v$hpbsz){StJEl zpP9OlN;?)85SjXsPGD0#!}!rg38$;8i~0wirQY6NYH#1av-MsB*=$zdHaGvXvt3$x zpthT|`tMarGGcIGL{friZ*Qlu=bxkg{%7?Kj~fAkf|%~!y+=#;i=s*ri4YJ((m&8o z6BEa@X!a6_>E_K_bn~NIq2Gh!8y-2L#Q2G}Xi*YjtObM#@Qt-YT@BfLSKr98+O5v2_38|7rnw)%*CSUq;#rIvhbXkkfbnH|reU4%T zT-C{wuhQ`F5o&8|eU6~rlR6f}U8@ft>Hys1>)-fVRe>XbhbuIl*koZi5tA>y_!)O# z3J}EWuYa3nXFsItGk?&Jy)3Wt{!dX9h=qXk$@$Jn-xFdA5cu%$VY>9ruYC85!v6vy zFQBop=Tsp2m4;%S)zybOdszs{&`Nv zAwi-E`+2(Nqi#6aV`HN_SdNPOX$FxLP_5!=ws0!prR(?`h;vXzXtadTm3oRTP_vns z7_Uh@7-G0S^T#K4A|Oy*_qlmnA17k8I1?!WA!tRw90q+NRW%nDp%p-|zkBcZdT0XW zg?g+}(G{9qOpJ_x@MV7M)o+H{7bg#Cax2U~juY++se18PiX->62H8;|G6I4-2oszl z1^>;jf7>$gl-Hvg38-5=3=@V(2nd-?m{G3Oil&| z&9FGIJgpKBXm+be6oxa57f_gAbt_|@q+_&zip9BL*YzaRbU#W*+M5Bpum%ZJ@0p#Q z)3qKGK-%6)7upJp6;K%KqpQE~e?X>y0^^@QZLhLnn{blffq z7%8Cs{{Fzs%OuxTE8bL~X1TgnVq^?7_PqbqG^1kPlYo%|3UXf<>1NEpH~}FJ2y$Pw zOxk1i7V`w8G8R7&lyb${9E=lCko$^5;29^36Ht)*io$HoBw=I>l3;zj^@~cs3%xvzDz4^pd0JjxA(Jr5 zg$4KhG`C&CL=GM5tNKHNh)h^8q7+^u(J(qXJFC8Cx7o(^2pJ`yZ1xc`{@AunCSjC- zyhSXl;VpW=n1f*!C|h-DHw4gVbBH_M3bED5eqvVtlX2}F=!5Yx5ih7l^%?D=Ny)v(IvCcG zHVqb#L7+d{kL@kCKui@7Ja=?-`0it3C7uOj2tDZ@-`9~yY_SDms(~>uKTI75WC0mK z@e))1^`Eu1H8PP%tPg|{=)OarqrSdAeSdpcrB;e{_6)lbvs(>&&Z4g`~jauO9pqah2u z#wL(Xr$a4?XR`GH#tSIdX<(q4P*4A5>4kKv(!szEi#bM}yib5NU@mW|i)9+UPsv#PPCrm~O*Azlc`E_YF1U-4U#=z=76_S3*aBT8AcRK5P%HXs&mY~2#eBC&45kKs z(fLANPkUa8KoE*!{Oe!-7MsbEk+49R4^_>&=$WgJU5%^K;58M~V8q?DWl(UO=tqj% zWXVWaAiv6NdCA{fgu#^NF~mBkL&56Aai*;p92k)l0d)3M*1ZRJ0IO4t;uUkc^!uK) zMS^fvmj6X7D=U%h)eb{s1Oy-gWI(pvrpC5vOTSSA!mz?#UxyU^%MdoKO`L_}5h(#R z1J5cUg@>3LU2_m3foLF@hiVi(38E<_aRMp_>H7gy=jlqNX#f8G#kHB$2tMjPp18bN zDxc5O*4B1$``;}+Q#5L0SIs8z0%972XQna@p#&scVwk!Ru0=v-h+*nLDD7B6l1mIz z2U604C52odhN%ZZbC#4={1;zl_5>xKIZM?$W{6?x!QM9~upJyY`myq84lztfgb?n+ z;y(@->p-G#l^CW5Q~*7Ltrn0~5Lbv{YQVDPG`7NfVrXz=U8$;@7$yMEec}GjP=+kf zMPir`sM_Iq@BWT}thU?*A&9HeOb{6=$=r7YE4l+&(z+w~sq8jWWw#Q1^O z$78vC=P7G6q8hQV*yLe}&^f^&!$Dx6dYf3~@#4wL3&Advf z*ia_ywjZ%8V?3Y*VcD-#dadF$q%)a^8FppN4h8zj!u>n%R(h%GMaVN7Yd2C|Jv~Z( zlf)z!LFX5j=HIOLvhP*wH#XPbO{EUP?~N0aK*aRo{rNM#dr%0}FvqMK=3o&e2o?9t zEiBGY1nn1URz~rI#56GpK}?4c53ngD8=GraQeA1GY}OPp@kPpuD&~13?Czlj1eAzo zdb-maN<;{(4-gX@blFB_TZ<3wo~!A7R0obT>c~R=nv~qd7zdOHW>EVp9pkFuz4hY^ z96cokon;|K6IdOYS84i#`3qDhHMy{IGMj62Jw4A{Rn|xdON zh$rh7*KnB2Ry{wCGF?WBM>;wk`iy04CUU0zosN(dL}&H10!CqaUOBB{Snd_3M|)xJ zwOf?e_B5l$&@B&p64RF3(NJb(<+iErLF3R3>3!u(!%v|g2T(X&F4z}vDR_-1eo<05 zMcx~Y|Cb-4q`Z3mN%8ZRErk_Xu+p;tMceMD+|^I*#06Ne@n!BERis~C6w20nIdP=< z!wuWU&bfW%4OuG=L&L87EY_%R8YMHZNDP+0ko4aFkAr?>I+tD+tbT}sRh)#Tvr3r(5pf-I^yn_?;|Ol_{h`rMdY2?st`;4v0U=5JdbC-Kem zfR58s$9*AdTWx`W#HzMtoy&cplNQaiZoQ;|`!Z}!Dbz6x$p(nvq2u#}2fEd#&CU;= zSr7l{di!8LOx#1YOT`h9;N7HohNFmKNHIE_kz2m~PUquFE<@q()5$mbh_df{UM<5lhss$;t`Z2ag2e(G58}DqvUUt& zzC37@Cp{iGwbRj%t*}BnI-t5G8un9o<}h)xWwyM9y31tA6taJt8zEO@C8)8E2w8g| zYSlh4O~{_f8sE<<@#Q0|8k&BJRw*9U9|3q;u6205t{wF8gq$0o_?vvQ)s)&$eW7Tk zGc7{s0AsBg@Ol}3@$y0{G3N&|QZGXIg3!4#YguZeOF<1Ve~m)yJ{^~L)!y78nv=&$ zhVV|EuauPR?J>S2j`C(xEOj_F>L(0_ehm&pAuSOydM^L=jo{r2zvp##Ex5DvWR_T09 z8iN_$T6HX)F)Q74L9LWqz;^|AKKsjNiPdt~uA!1Yy)uHVaCL!G2mP@&R!o*Hr7p8G4sY2RmoKt5WMc zt7CleytzO^eE9&y;?+DKumCg@K`(FfIg!&?TbX%Rv_=6lhQ`at`wo8%N>Vf!-yx(@ z&nV8TAu+ZfM>j>rSfFs|A@?R76SKnnado_!wWEk$XH#<>m$?<%vpIiWO)QqxYwiWS z7@I4NV@?lx$KV`a?3nGkeS_&pO&j}E5JL()Q2qO6cugV-HM7p+{VQtIEkalm9|6Rt zrFfV@@8NlP#5CM4cEa?O`$d}Ra&L=D-ZDrS?)p``AB!Ogs=CbH89FuUiXUnhA42#t zE;u)^vJ2bg?U9ycO}yrj7gP#GB~g83U(awfrYx>d>2$6B6Sq6KBx4qZ(ddJToSL~a z-}}!N0Lr%3PEVHn-K&b|DG%b3U}>zGtxUxy493Glp4L1y3dnjUJ~H}^w%s7Qq`?kD z+M%c&@mj3w=S;S-mOu1OETAPxuqTqv)K)7hnU)OpHdO_An=b4 z&>*Hvl6s5-4^`~?kM130wb%kk_4i$*$vv^7)~>Kx&hU*#SXjuFculhf`nKgy&jb*w zXj}}o?EWpV#J?o?zv%`|f?x)Y5}pD#{Pe>+n$4L6F>S1st&q*bNH%R5%U@2uL|BS4 zMqdPv7fK+jb);i~X09AFdav3-Y8`qIqYm|%c83Ckxyem^^uqKtl**l;k|_^Ijz#I3?K+a~U$nm0-~N=Qyxv2M*WtR*QS`}# zN@fzfw|~+sulT1B`JZS!D9#|O;zYP#&?&^HG_DKTvTZM8IFT_;ezVCdA_$;6!hp?G zGA7VGzttpJcXiO^r!w%8-+AXhrq3y&8!tcT?&nwNS&%GRcLHXRcTE=c6}^?GT14$? zlC{*258HMEUQzu3R_L$j=`=~cwZy<%5R%B{5uf0yle<%Rx0V|$AaD|JH@!I=$!sZ_ zTDjg}!e^cNw^$wKPeVAJ@i$Tu3#VQ6EeiL`CRey2lBb82Xd3@cM&-;wH# zfp)n>?WPt#!A!$oGZ7}BfRj8zXzp(?ayasB?%1vG1t@L4U+*Z*O}5(`F!a0O^53T| z|4Dk7Xw%#C-s1p75BLBT#-}GI(L7MJ>q{nTdzbubw!0bK8OB>Ijk*aO#)G2cdUM5j zX{+epE-W>o8@;SjAjO;(`BWj%#`W7du}RU<;mBA@rk2fF&F^2Jy-Um#l?w!UKUZC& zHGB>Gc~$7|pjW4tQ3dy^rM}aAxLkph<7*-JbM6lZI2%o9s6+xt0?8-urcQr+?@*8S zscN;a^sP~J$s%D-5XbY@L*(mBfkL(j?%gBuyHFul7tXv)qxb1fLn?+uVpa;3$qjGE zdV&OVyvC2!(KGy3s2PQKI50q1*O$)vn#~@HPg&&lj*Ucr?^|j-vQ_$ zu<=ivfOs6WM;kfS;r+r{$*Fpa#t!!*jEllFDL;GcP(!A5YrvFTy7k3a&kebZwf>DefSKuEhpDAL0Uyh ztB0DzkER}xCq+&5;kWb0)}IGxG=S{e+dV6#g$qjtH{Z?qC(HT&d@LWdZcb8Sah_Byg*MBn29@y%qMp@g<1bAP26qyV^;5)CuOwpx>} zduz}*+QVbFY{lO67v<` zKyF@^SVvc39y4c9+n@f?hocx$o44`8$L7fec71@=nu7EmsB$+T$fG73Lt+^YZs^(Y zb$1funVT5IYW`L!(mcJQHCG1Nt8Y0!_-Ly9C*t?*0jPlG$Ds;DDwe}EJB^$A&{7?$ zgxs7LuuM&Rb|s8|#s9P_jWZ&^{xVgQxv|1eV3YHh(Y-ukT$A`K4wIhaFmadl=+WvW z320*do``ctbIbG)JL?}XPfcM}+Q{WebTY#^Fz2@-mmn^y!o;TZ1k5HMqG;<;gZjbi zKs-hz0r>5SRzcvmV!4TBYIV2*>-ao#ZWmZ5w;N;Be)O8KdK5*;#<b!YFGAE=p``|gZOftq1M>$3 zGnb9BFlTS$dc6&lI(br$|2-Eh_;Hx|A?GWuJzPie;!mYGKbAXbDk%yyhA&2rqCVs# zbSFh9#-X-H;2B?iQy`6^27v#l_a>Uka1U?4c%o<$M;j5z#Dw8Ifd0D`e{(1iX4Ds5 z>!4c2HZHqa*SAhV$yY=Ue*hNq$A!=%{u^d%!uV2(_`m^lcg->6|1YZ;34PMTBk^Zq z(9k9yrHpx1P^6hokrjSX{8x-PsAf=qQ&QIF7jrO(!ptz)zpR@D~(>w9UnKj=MgVfo-`^$t43L zum&|t^_~l`+{QdtIUf|90&4}PK>O~TiPLT0+ufhF`ZKn*f4W296y$NJDoqWA@m@~=vBc7nBzmaM`p&K`1zT=46(ee zbKB(p<#L;}vaWukT>j=|XW&WKy{X>dt%BlW|M>HR8nI&#{Znv5Os(xx9{54OYi?4z|`^tuc3GQwgJ^%5Z!b-3HSyPOQi`;u0U zL5wzqjkAz=e3(}HWwyB30+8 { const [page, setPage] = useState(1); const [isLoadingNextPage, setIsLoadingNextPage] = useState(false); const [pageData, setPageData] = useState([]); - const [walletData, setWalletData] = useState( - undefined - ); const walletAddress = useWalletAddress(); const scrollPositionRef = useRef(0); @@ -64,16 +61,6 @@ const App = () => { { page, address: walletAddress || '' }, { skip: !walletAddress } ); - const { - data: walletTile, - isLoading: isWalletTileLoading, - isFetching: isWalletTileFetching, - isSuccess: isWalletTileSuccess, - refetch: refetchWalletTile, - } = useGetWalletInfoQuery( - { address: walletAddress || '' }, - { skip: !walletAddress } - ); // This is a "fire and forget" call to the waitlist const { data: waitlistData, @@ -84,22 +71,6 @@ const App = () => { { skip: !walletAddress } ); - // This useEffect is to update the wallet data - useEffect(() => { - if (!isWalletTileSuccess && walletAddress) { - refetchWalletTile(); - } - - if (walletTile && isWalletTileSuccess) { - setWalletData(walletTile); - } - - if (!isWalletTileSuccess) { - setWalletData(undefined); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletTile, isWalletTileSuccess, walletAddress]); - useEffect(() => { if (!isHomeFeedSuccess && walletAddress) { refetchHomeFeed(); @@ -214,10 +185,7 @@ const App = () => { ref={divRef} className="flex flex-col gap-[40px] tablet:gap-[28px] mobile:gap-[32px]" > - + {DisplayHomeFeedTiles} {(isHomeFeedFetching || isHomeFeedLoading) && page === 1 && ( <> diff --git a/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts b/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts new file mode 100644 index 00000000..747819a8 --- /dev/null +++ b/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts @@ -0,0 +1,155 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { sub } from 'date-fns'; + +// types +import { + PortfolioData, + TokenPriceGraphPeriod, + WalletHistory, +} from '../../../types/api'; + +// utils +import { convertDateToUnixTimestamp } from '../../../utils/common'; +import { PeriodFilterBalance, PeriodFilterPnl } from '../utils/portfolio'; + +export type WalletPortfolioState = { + walletPortfolio: PortfolioData | undefined; + isWalletPorfolioLoading: boolean; + isWalletPortfolioErroring: boolean; + walletPortfolioWithPnl: PortfolioData | undefined; + isWalletPorfolioWithPnlLoading: boolean; + isWalletPortfolioWithPnlErroring: boolean; + priceGraphPeriod: TokenPriceGraphPeriod; + periodFilter: PeriodFilterBalance; + periodFilterPnl: PeriodFilterPnl; + walletHistoryGraph: WalletHistory | undefined; + isWalletHistoryGraphLoading: boolean; + isWalletHistoryGraphErroring: boolean; + topTokenUnrealizedPnL: WalletHistory | undefined; + isTopTokenUnrealizedPnLLoading: boolean; + isTopTokenUnrealizedPnLErroring: boolean; + selectedBalanceOrPnl: 'balance' | 'pnl'; + isRefreshAll: boolean; + isReceiveModalOpen: boolean; +}; + +const initialState: WalletPortfolioState = { + walletPortfolio: undefined, + isWalletPorfolioLoading: false, + isWalletPortfolioErroring: false, + walletPortfolioWithPnl: undefined, + isWalletPorfolioWithPnlLoading: false, + isWalletPortfolioWithPnlErroring: false, + priceGraphPeriod: { + from: convertDateToUnixTimestamp(sub(new Date(), { days: 1 })), + to: undefined, + }, + periodFilter: PeriodFilterBalance.DAY, + periodFilterPnl: PeriodFilterPnl.DAY, + walletHistoryGraph: undefined, + isWalletHistoryGraphLoading: false, + isWalletHistoryGraphErroring: false, + topTokenUnrealizedPnL: undefined, + isTopTokenUnrealizedPnLLoading: false, + isTopTokenUnrealizedPnLErroring: false, + selectedBalanceOrPnl: 'balance', + isRefreshAll: false, + isReceiveModalOpen: false, +}; + +const walletPortfolioSlice = createSlice({ + name: 'walletPortfolio', + initialState, + reducers: { + setWalletPortfolio( + state, + action: PayloadAction + ) { + state.walletPortfolio = action.payload; + }, + setIsWalletPorfolioLoading(state, action: PayloadAction) { + state.isWalletPorfolioLoading = action.payload; + }, + setIsWalletPortfolioErroring(state, action: PayloadAction) { + state.isWalletPortfolioErroring = action.payload; + }, + setWalletPortfolioWithPnl( + state, + action: PayloadAction + ) { + state.walletPortfolioWithPnl = action.payload; + }, + setIsWalletPortfolioWithPnlLoading(state, action: PayloadAction) { + state.isWalletPorfolioWithPnlLoading = action.payload; + }, + setIsWalletPortfolioWithPnlErroring(state, action: PayloadAction) { + state.isWalletPortfolioWithPnlErroring = action.payload; + }, + setPriceGraphPeriod(state, action: PayloadAction) { + state.priceGraphPeriod = action.payload; + }, + setPeriodFilter(state, action: PayloadAction) { + state.periodFilter = action.payload; + }, + setPeriodFilterPnl(state, action: PayloadAction) { + state.periodFilterPnl = action.payload; + }, + setWalletHistoryGraph( + state, + action: PayloadAction + ) { + state.walletHistoryGraph = action.payload; + }, + setIsWalletHistoryGraphLoading(state, action: PayloadAction) { + state.isWalletHistoryGraphLoading = action.payload; + }, + setIsWalletHistoryGraphErroring(state, action: PayloadAction) { + state.isWalletHistoryGraphErroring = action.payload; + }, + setTopTokenUnrealizedPnL( + state, + action: PayloadAction + ) { + state.topTokenUnrealizedPnL = action.payload; + }, + setIsTopTokenUnrealizedPnLLoading(state, action: PayloadAction) { + state.isTopTokenUnrealizedPnLLoading = action.payload; + }, + setIsTopTokenUnrealizedPnLErroring(state, action: PayloadAction) { + state.isTopTokenUnrealizedPnLErroring = action.payload; + }, + setSelectedBalanceOrPnl(state, action: PayloadAction<'balance' | 'pnl'>) { + state.selectedBalanceOrPnl = action.payload; + }, + setIsRefreshAll(state, action: PayloadAction) { + state.isRefreshAll = action.payload; + }, + setIsReceiveModalOpen(state, action: PayloadAction) { + state.isReceiveModalOpen = action.payload; + }, + }, +}); + +export const { + setWalletPortfolio, + setIsWalletPorfolioLoading, + setIsWalletPortfolioErroring, + setWalletPortfolioWithPnl, + setIsWalletPortfolioWithPnlLoading, + setIsWalletPortfolioWithPnlErroring, + setPriceGraphPeriod, + setPeriodFilter, + setPeriodFilterPnl, + setWalletHistoryGraph, + setIsWalletHistoryGraphLoading, + setIsWalletHistoryGraphErroring, + setTopTokenUnrealizedPnL, + setIsTopTokenUnrealizedPnLLoading, + setIsTopTokenUnrealizedPnLErroring, + setSelectedBalanceOrPnl, + setIsRefreshAll, + setIsReceiveModalOpen, +} = walletPortfolioSlice.actions; + +export default walletPortfolioSlice; diff --git a/src/apps/pillarx-app/tailwind.pillarx.config.js b/src/apps/pillarx-app/tailwind.pillarx.config.js index 219521bd..b494d705 100644 --- a/src/apps/pillarx-app/tailwind.pillarx.config.js +++ b/src/apps/pillarx-app/tailwind.pillarx.config.js @@ -8,7 +8,7 @@ module.exports = { darkMode: 'class', theme: { screens: { - desktop: { min: '1024px' }, + desktop: { min: '1025px' }, tablet: { max: '1024px' }, mobile: { max: '768px' }, xs: { max: '470px' }, @@ -16,13 +16,15 @@ module.exports = { extend: { colors: { deep_purple: { A700: '#5e00ff' }, - container_grey: '#27262F', + container_grey: '#1E1D24', medium_grey: '#312F3A', purple_light: '#E2DDFF', purple_medium: '#8A77FF', percentage_green: '#05FFDD', percentage_red: '#FF366C', market_row_green: '#5CFF93', + dark_blue: '#2E2A4A', + lighter_container_grey: '#25232D', }, fontFamily: { custom: ['Formular'], diff --git a/src/apps/pillarx-app/utils/constants.ts b/src/apps/pillarx-app/utils/constants.ts index 3b0b7c6a..a7cd8a09 100644 --- a/src/apps/pillarx-app/utils/constants.ts +++ b/src/apps/pillarx-app/utils/constants.ts @@ -1 +1,14 @@ +import { PrimeAssetType } from '../../../types/api'; + export const PAGE_LIMIT: number = 4; + +export const PRIME_ASSETS_MOBULA: PrimeAssetType[] = [ + { name: 'Ethereum', symbol: 'ETH' }, + { name: 'XDAI', symbol: 'XDAI' }, + { name: 'USDC', symbol: 'USDC' }, + { name: 'Tether', symbol: 'USDT' }, + { name: 'Polygon', symbol: 'MATIC' }, + { name: 'POL (ex-MATIC)', symbol: 'POL' }, + { name: 'BNB', symbol: 'BNB' }, + { name: 'Dai', symbol: 'DAI' }, +]; diff --git a/src/apps/pillarx-app/utils/portfolio.ts b/src/apps/pillarx-app/utils/portfolio.ts new file mode 100644 index 00000000..b7ce0ceb --- /dev/null +++ b/src/apps/pillarx-app/utils/portfolio.ts @@ -0,0 +1,38 @@ +export enum PeriodFilterBalance { + HOUR = 'HOUR', + DAY = 'DAY', + WEEK = 'WEEK', + MONTH = 'MONTH', + HALF_YEAR = 'HALF_YEAR', +} + +export enum PeriodFilterPnl { + DAY = 'DAY', + WEEK = 'WEEK', + MONTH = 'MONTH', + YEAR = 'YEAR', +} + +export const getGraphResolutionBalance = ( + filter: PeriodFilterBalance +): string => { + switch (filter) { + case PeriodFilterBalance.HOUR: + // every 5 min + return '5min'; + case PeriodFilterBalance.DAY: + // every 5 min + return '5min'; + case PeriodFilterBalance.WEEK: + // every hour + return '1h'; + case PeriodFilterBalance.MONTH: + // every 6h + return '6h'; + case PeriodFilterBalance.HALF_YEAR: + // every day + return '1d'; + default: + return '1h'; + } +}; diff --git a/src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx b/src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx index 066547b8..1d858c2a 100644 --- a/src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx +++ b/src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx @@ -106,7 +106,7 @@ const DropdownTokenList = ({ isSuccess: isWalletPortfolioDataSuccess, error: walletPortfolioDataError, } = useGetWalletPortfolioQuery( - { wallet: accountAddress || '' }, + { wallet: accountAddress || '', isPnl: false }, { skip: !accountAddress } ); diff --git a/src/services/pillarXApiWalletHistory.ts b/src/services/pillarXApiWalletHistory.ts new file mode 100644 index 00000000..0394a7b4 --- /dev/null +++ b/src/services/pillarXApiWalletHistory.ts @@ -0,0 +1,63 @@ +import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react'; + +// types +import { WalletHistoryMobulaResponse } from '../types/api'; + +// store +import { addMiddleware } from '../store'; + +// utils +import { CompatibleChains, isTestnet } from '../utils/blockchain'; + +const fetchBaseQueryWithRetry = retry( + fetchBaseQuery({ + baseUrl: isTestnet + ? 'https://hifidata-nubpgwxpiq-uc.a.run.app' + : 'https://hifidata-7eu4izffpa-uc.a.run.app', + headers: { + 'Content-Type': 'application/json', + }, + }), + { maxRetries: 5 } +); + +export const pillarXApiWalletHistory = createApi({ + reducerPath: 'pillarXApiWalletHistory', + baseQuery: fetchBaseQueryWithRetry, + endpoints: (builder) => ({ + getWalletHistory: builder.query< + WalletHistoryMobulaResponse, + { wallet: string; period: string; from: number; to?: number } + >({ + query: ({ wallet, period, from, to }) => { + const chainIds = isTestnet + ? [11155111] + : CompatibleChains.map((chain) => chain.chainId); + const chainIdsQuery = chainIds.map((id) => `chainIds=${id}`).join('&'); + + return { + url: `?${chainIdsQuery}&testnets=${String(isTestnet)}`, + method: 'POST', + body: { + path: 'wallet/history', + params: { + wallet, + blockchains: CompatibleChains.map((chain) => chain.chainId).join( + ',' + ), + period, + from: from * 1000, + to: to ? to * 1000 : undefined, + unlistedAssets: 'true', + filterSpam: 'true', + }, + }, + }; + }, + }), + }), +}); + +addMiddleware(pillarXApiWalletHistory); + +export const { useGetWalletHistoryQuery } = pillarXApiWalletHistory; diff --git a/src/services/pillarXApiWalletPortfolio.ts b/src/services/pillarXApiWalletPortfolio.ts index 016bd186..dba19e87 100644 --- a/src/services/pillarXApiWalletPortfolio.ts +++ b/src/services/pillarXApiWalletPortfolio.ts @@ -1,18 +1,27 @@ +/* eslint-disable no-restricted-syntax */ import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react'; // types -import { PortfolioData, WalletPortfolioMobulaResponse } from '../types/api'; +import { + AssetMobula, + ContractsBalanceMobula, + PortfolioData, + PrimeAssetType, + WalletPortfolioMobulaResponse, +} from '../types/api'; // store import { addMiddleware } from '../store'; // utils import { CompatibleChains, isTestnet } from '../utils/blockchain'; -import { Token, chainIdToChainNameTokensData } from './tokensData'; + +// services +import { PortfolioToken, chainIdToChainNameTokensData } from './tokensData'; export const convertPortfolioAPIResponseToToken = ( portfolioData: PortfolioData -): Token[] => { +): PortfolioToken[] => { if (!portfolioData) return []; return portfolioData.assets.flatMap((asset) => @@ -30,10 +39,98 @@ export const convertPortfolioAPIResponseToToken = ( decimals: contract.decimals, balance: contract.balance, price: asset.price, + price_change_24h: asset.price_change_24h, + cross_chain_balance: asset.token_balance, })) ); }; +export const getPrimeAssetsWithBalances = ( + walletPortfolio: PortfolioData, + primeAssets: PrimeAssetType[] +): { + name: string; + symbol: string; + primeAssets: { asset: AssetMobula; usd_balance: number }[]; +}[] => { + return primeAssets.map(({ name, symbol }) => { + const primeAssetsMatch = walletPortfolio.assets + .filter( + (assetData) => + assetData.asset.name === name && assetData.asset.symbol === symbol + ) + .map((assetData) => ({ + asset: assetData.asset, + usd_balance: assetData.estimated_balance, + })); + + return { + name, + symbol, + primeAssets: primeAssetsMatch, + }; + }); +}; + +export const getTopNonPrimeAssetsAcrossChains = ( + walletPortfolio: PortfolioData, + primeAssets: PrimeAssetType[] +): { + asset: AssetMobula; + usdBalance: number; + tokenBalance: number; + unrealizedPnLUsd: number; + unrealizedPnLPercentage: number; + contract: ContractsBalanceMobula; + price: number; +}[] => { + const primeAssetSet = new Set( + primeAssets.map((a) => `${a.name}|${a.symbol}`) + ); + + // Here we are filtering the tokens and removing the ones that are Prime Assets + // We then select the top three tokens with the highest USD value + const nonPrimeAssetBalances = walletPortfolio.assets + // Filter out assets that are prime assets + .filter( + (assetData) => + !primeAssetSet.has(`${assetData.asset.name}|${assetData.asset.symbol}`) + ) + // Flat map to recreate an array of assets with their balances + .flatMap((assetData) => + assetData.contracts_balances.map((contract) => { + const usdBalance = contract.balance * assetData.price; + const priceChangePercent = assetData.price_change_24h; + + const previousBalance = + priceChangePercent === -100 + ? 0 + : usdBalance / (1 + priceChangePercent / 100); + + const unrealizedPnLUsd = usdBalance - previousBalance; + + const unrealizedPnLPercentage = + previousBalance > 0 ? (unrealizedPnLUsd / previousBalance) * 100 : 0; + + return { + asset: assetData.asset, + usdBalance, + tokenBalance: contract.balance, + unrealizedPnLUsd, + unrealizedPnLPercentage, + contract, + price: assetData.price, + }; + }) + ); + + const topThree = nonPrimeAssetBalances + .sort((a, b) => b.usdBalance - a.usdBalance) + .slice(0, 3); + + return topThree; +}; + const fetchBaseQueryWithRetry = retry( fetchBaseQuery({ baseUrl: isTestnet @@ -53,9 +150,9 @@ export const pillarXApiWalletPortfolio = createApi({ endpoints: (builder) => ({ getWalletPortfolio: builder.query< WalletPortfolioMobulaResponse, - { wallet: string } + { wallet: string; isPnl: boolean } >({ - query: ({ wallet }) => { + query: ({ wallet, isPnl }) => { const chainIds = isTestnet ? [11155111] : CompatibleChains.map((chain) => chain.chainId); @@ -73,6 +170,7 @@ export const pillarXApiWalletPortfolio = createApi({ ), unlistedAssets: 'true', filterSpam: 'true', + pnl: isPnl, }, }, }; diff --git a/src/services/tokensData.ts b/src/services/tokensData.ts index a19eddc9..35069bc9 100644 --- a/src/services/tokensData.ts +++ b/src/services/tokensData.ts @@ -20,6 +20,11 @@ export type Token = { price?: number; }; +export type PortfolioToken = Token & { + price_change_24h?: number; + cross_chain_balance?: number; +}; + export type TokenRawDataItem = { id: number; name: string; diff --git a/src/store.ts b/src/store.ts index c80694e3..5d0bbbf6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -11,6 +11,7 @@ import { setupListeners } from '@reduxjs/toolkit/query'; // Services import depositSlice from './apps/deposit/reducer/depositSlice'; +import walletPortfolioSlice from './apps/pillarx-app/reducer/WalletPortfolioSlice'; import swapSlice from './apps/the-exchange/reducer/theExchangeSlice'; import tokenAtlasSlice from './apps/token-atlas/reducer/tokenAtlasSlice'; import { pillarXApiPresence } from './services/pillarXApiPresence'; @@ -85,6 +86,7 @@ addMiddleware(pillarXApiTransactionsHistory); addReducer(swapSlice); addReducer(tokenAtlasSlice); addReducer(depositSlice); +addReducer(walletPortfolioSlice); // optional, but required for refetchOnFocus/refetchOnReconnect behaviors // see `setupListeners` docs - takes an optional callback as the 2nd arg for customization diff --git a/src/theme/index.ts b/src/theme/index.ts index e3f6e306..6a15eb13 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -89,7 +89,7 @@ export const defaultTheme: Theme = { }, color: { background: { - body: '#1F1D23', + body: '#121116', bottomMenu: 'rgba(18, 15, 23, 0.70)', bottomMenuItemHover: 'rgba(216, 232, 255, 0.10)', bottomMenuModal: 'rgba(16, 16, 16, 0.70)', diff --git a/src/types/api.ts b/src/types/api.ts index 889e62df..329ab024 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -694,11 +694,13 @@ export type AssetDataMobula = { max_buy_price?: number; }; +export type PnLEntry = [string, { realized: number; unrealized: number }]; + export type PnLHistory = { - '24h': string[][]; - '7d': string[][]; - '30d': string[][]; - '1y': string[][]; + '24h': PnLEntry[]; + '7d': PnLEntry[]; + '30d': PnLEntry[]; + '1y': PnLEntry[]; }; export type TotalPnLHistory = { @@ -735,6 +737,16 @@ export type WalletPortfolioMobulaResponse = { result: { data: PortfolioData }; }; -// export type WalletBalances = { -// tokenName: string; -// }; +export type BalanceHistoryEntry = [timestamp: number, balance: number]; + +export type WalletHistory = { + wallets: string[]; + balance_usd: number; + balance_history: BalanceHistoryEntry[]; +}; + +export type WalletHistoryMobulaResponse = { + result: { data: WalletHistory }; +}; + +export type PrimeAssetType = { name: string; symbol: string }; diff --git a/src/utils/number.tsx b/src/utils/number.tsx index 9b627873..6321d141 100644 --- a/src/utils/number.tsx +++ b/src/utils/number.tsx @@ -1,3 +1,5 @@ +import { parseInt } from 'lodash'; + export const formatAmountDisplay = ( amountRaw: string | number, minimumFractionDigits?: number, @@ -36,3 +38,32 @@ export const isValidAmount = (amount?: string): boolean => { // eslint-disable-next-line no-restricted-globals return !isNaN(+amount); }; + +export const limitDigitsNumber = (num: number): number => { + // Handle zero or undefined number + if (num === 0 || !num) return 0; + + // Convert number to string with a large number of decimals to make sure it covers all decimals + const numStr = num.toFixed(20); + const [integerPart, fractionalPart] = numStr.split('.'); + + // If integer part is greater than 0 it will show between 2 and 4 decimals + if (parseInt(integerPart) > 0) { + if (parseInt(integerPart) >= 1000) { + return Number(num.toFixed(2)); + } + return Number(num.toFixed(4)); + } + // If integer part is equal to 0 it will find the position of the first non-zero digit + const firstNonZeroIndex = fractionalPart.search(/[1-9]/); + + // If we do not find 0, return 0 + if (firstNonZeroIndex === -1) return 0; + + // Show up to firstNonZeroIndex + 2-4 significant digits + const significantDigits = 4; // Show first non-zero digit plus 3 more (4 significant) + const decimalPlaces = firstNonZeroIndex + significantDigits; + + // Ensure we have at least those digits in the fractional part + return Number(num.toFixed(decimalPlaces)); +}; From 59a617a4a420ed6723d50c2f53ebc6487714674d Mon Sep 17 00:00:00 2001 From: RanaBug Date: Wed, 21 May 2025 11:46:21 +0100 Subject: [PATCH 2/2] unit tests and minor fixes --- .../__snapshots__/EditorialTile.test.tsx.snap | 2 +- .../GenericBannerTile.test.tsx.snap | 2 +- .../HighlightedMediaGridTile.test.tsx.snap | 2 +- .../DisplayCollectionImage.test.tsx.snap | 82 ++++--- .../PortfolioOverview.test.tsx.snap | 45 ++-- .../test/PrimeTokensBalance.test.tsx | 132 +++++++++++ .../PrimeTokensBalance.test.tsx.snap | 112 +++++++++ .../{ => ReceiveModal}/ReceiveModal.tsx | 17 +- .../ReceiveModal/test/ReceiveModal.test.tsx | 116 ++++++++++ .../__snapshots__/ReceiveModal.test.tsx.snap | 62 +++++ .../TileContainer/test/TileContainer.test.tsx | 4 +- .../__snapshots__/TileContainer.test.tsx.snap | 6 +- .../RightColumnTokenMarketDataRow.test.tsx | 2 +- ...LeftColumnTokenMarketDataRow.test.tsx.snap | 4 +- ...ightColumnTokenMarketDataRow.test.tsx.snap | 4 +- .../TokensHorizontalTile.test.tsx.snap | 2 +- .../TokensVerticalTile.test.tsx.snap | 2 +- .../test/TokensWithMarketDataTile.test.tsx | 4 +- .../TokensWithMarketDataTile.test.tsx.snap | 52 ++--- .../components/TopTokens/TopTokens.tsx | 14 +- .../TopTokens/test/TopTokens.test.tsx | 158 +++++++++++++ .../__snapshots__/TopTokens.test.tsx.snap | 118 ++++++++++ .../WalletConnectDropdown.test.tsx.snap | 41 ++-- .../WalletPortfolioBalance.tsx | 20 +- .../test/WalletPortfolioBalance.test.tsx | 166 +++++++++++++ .../WalletPortfolioBalance.test.tsx.snap | 136 +++++++++++ .../WalletPortfolioButtons.tsx | 7 +- .../WalletPortfolioGraph/BalancePnlGraph.tsx | 12 +- .../WalletPortfolioGraphButton.tsx | 5 +- .../tests/WalletPortfolioGraph.test.tsx | 156 +++++++++++++ .../WalletPortfolioGraph.test.tsx.snap | 218 ++++++++++++++++++ .../WalletPortfolioTile.tsx | 4 +- .../test/WalletPortfolioTile.test.tsx | 183 +++++++++++++++ .../WalletPortfolioTile.test.tsx.snap | 156 +++++++++++++ .../reducer/WalletPortfolioSlice.ts | 16 +- src/services/pillarXApiWalletPortfolio.ts | 2 +- src/utils/number.tsx | 6 +- 37 files changed, 1897 insertions(+), 173 deletions(-) create mode 100644 src/apps/pillarx-app/components/PrimeTokensBalance/test/PrimeTokensBalance.test.tsx create mode 100644 src/apps/pillarx-app/components/PrimeTokensBalance/test/__snapshots__/PrimeTokensBalance.test.tsx.snap rename src/apps/pillarx-app/components/{ => ReceiveModal}/ReceiveModal.tsx (90%) create mode 100644 src/apps/pillarx-app/components/ReceiveModal/test/ReceiveModal.test.tsx create mode 100644 src/apps/pillarx-app/components/ReceiveModal/test/__snapshots__/ReceiveModal.test.tsx.snap create mode 100644 src/apps/pillarx-app/components/TopTokens/test/TopTokens.test.tsx create mode 100644 src/apps/pillarx-app/components/TopTokens/test/__snapshots__/TopTokens.test.tsx.snap create mode 100644 src/apps/pillarx-app/components/WalletPortfolioBalance/test/WalletPortfolioBalance.test.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioBalance/test/__snapshots__/WalletPortfolioBalance.test.tsx.snap create mode 100644 src/apps/pillarx-app/components/WalletPortfolioGraph/tests/WalletPortfolioGraph.test.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioGraph/tests/__snapshots__/WalletPortfolioGraph.test.tsx.snap create mode 100644 src/apps/pillarx-app/components/WalletPortfolioTile/test/WalletPortfolioTile.test.tsx create mode 100644 src/apps/pillarx-app/components/WalletPortfolioTile/test/__snapshots__/WalletPortfolioTile.test.tsx.snap diff --git a/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap b/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap index 804b2e60..3f84f9a0 100644 --- a/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap +++ b/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap @@ -2,7 +2,7 @@ exports[` renders correctly and matches snapshot 1`] = `
renders correctly and matches snapshot 1`] = `
renders correctly and matches snapshot 1`] = `

renders correctly and matches snapshot witho data-testid="random-avatar" fill="none" role="img" - viewBox="0 0 80 80" + viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" > - - - - - - - - - - - - - + +

`; diff --git a/src/apps/pillarx-app/components/PortfolioOverview/test/__snapshots__/PortfolioOverview.test.tsx.snap b/src/apps/pillarx-app/components/PortfolioOverview/test/__snapshots__/PortfolioOverview.test.tsx.snap index b5985d85..d11cb9d1 100644 --- a/src/apps/pillarx-app/components/PortfolioOverview/test/__snapshots__/PortfolioOverview.test.tsx.snap +++ b/src/apps/pillarx-app/components/PortfolioOverview/test/__snapshots__/PortfolioOverview.test.tsx.snap @@ -38,7 +38,7 @@ exports[` displays loading skeleton when data is loading 1` }
displays loading skeleton when data is loading 1` exports[` renders correctly and matches snapshot 1`] = `
renders correctly and matches snapshot 1`] = `
wallet-connect-logo renders correctly and matches snapshot 1`] = ` } />

WalletConnect

-
-
- arrow-down + viewBox="0 0 24 24" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/src/apps/pillarx-app/components/PrimeTokensBalance/test/PrimeTokensBalance.test.tsx b/src/apps/pillarx-app/components/PrimeTokensBalance/test/PrimeTokensBalance.test.tsx new file mode 100644 index 00000000..34b079e0 --- /dev/null +++ b/src/apps/pillarx-app/components/PrimeTokensBalance/test/PrimeTokensBalance.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +// services +import * as portfolioService from '../../../../../services/pillarXApiWalletPortfolio'; + +// utils +import * as numberUtils from '../../../../../utils/number'; + +// reducer +import * as reducerHooks from '../../../hooks/useReducerHooks'; + +// compoments +import PrimeTokensBalance from '../PrimeTokensBalance'; + +// types +import { store } from '../../../../../store'; +import { PortfolioData } from '../../../../../types/api'; + +jest.mock('../../../../../services/pillarXApiWalletPortfolio'); +jest.mock('../../../../../utils/number'); + +describe('', () => { + const useAppSelectorMock = jest.spyOn(reducerHooks, 'useAppSelector'); + + const mockGetPrimeAssetsWithBalances = jest.spyOn( + portfolioService, + 'getPrimeAssetsWithBalances' + ) as jest.Mock; + + const mockLimitDigitsNumber = jest.spyOn( + numberUtils, + 'limitDigitsNumber' + ) as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', () => { + const tree = render( + + + + ); + expect(tree).toMatchSnapshot(); + }); + + it('renders correctly when prime balance exists', () => { + const mockPortfolio = {} as PortfolioData; + + useAppSelectorMock.mockReturnValue(mockPortfolio); + + mockGetPrimeAssetsWithBalances.mockReturnValue([ + { + primeAssets: [{ usd_balance: 123.456 }, { usd_balance: 100 }], + }, + ]); + + mockLimitDigitsNumber.mockReturnValue(223.46); + + render(); + + expect( + screen.getByText('Prime Tokens Balance: $223.46') + ).toBeInTheDocument(); + expect(screen.getByAltText('prime-tokens-icon')).toHaveClass('w-4 h-4'); + expect(screen.getByAltText('prime-tokens-icon')).not.toHaveClass( + 'opacity-50' + ); + expect(screen.getByText('Prime Tokens Balance: $223.46')).not.toHaveClass( + 'text-opacity-50' + ); + }); + + it('renders balance as $0.00 when total balance is zero', () => { + const mockPortfolio = {} as PortfolioData; + + useAppSelectorMock.mockReturnValue(mockPortfolio); + + mockGetPrimeAssetsWithBalances.mockReturnValue([ + { primeAssets: [{ usd_balance: 0 }] }, + ]); + + mockLimitDigitsNumber.mockReturnValue(0); + + render(); + + expect(screen.getByText('Prime Tokens Balance: $0.00')).toBeInTheDocument(); + expect(screen.getByAltText('prime-tokens-icon')).toHaveClass('opacity-50'); + expect(screen.getByText('Prime Tokens Balance: $0.00')).toHaveClass( + 'text-opacity-50' + ); + }); + + it('renders $0.00 when no walletPortfolio exists', () => { + useAppSelectorMock.mockReturnValue(undefined); + + render(); + + expect(screen.getByText('Prime Tokens Balance: $0.00')).toBeInTheDocument(); + expect(screen.getByAltText('prime-tokens-icon')).toHaveClass('opacity-50'); + expect(screen.getByText('Prime Tokens Balance: $0.00')).toHaveClass( + 'text-opacity-50' + ); + }); + + it('shows tooltip on hover', () => { + useAppSelectorMock.mockReturnValue(undefined); + + render(); + + const helpIcon = screen.getByAltText('prime-tokens-question-icon'); + expect(helpIcon).toBeInTheDocument(); + + fireEvent.mouseEnter(helpIcon); + + expect( + screen.getByText( + /Prime Tokens are used for trading and paying gas fees across all chains/i + ) + ).toBeInTheDocument(); + + fireEvent.mouseLeave(helpIcon); + + expect( + screen.queryByText( + /Prime Tokens are used for trading and paying gas fees across all chains/i + ) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/apps/pillarx-app/components/PrimeTokensBalance/test/__snapshots__/PrimeTokensBalance.test.tsx.snap b/src/apps/pillarx-app/components/PrimeTokensBalance/test/__snapshots__/PrimeTokensBalance.test.tsx.snap new file mode 100644 index 00000000..863763c4 --- /dev/null +++ b/src/apps/pillarx-app/components/PrimeTokensBalance/test/__snapshots__/PrimeTokensBalance.test.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+ prime-tokens-icon +

+ Prime Tokens Balance: $ + 0.00 +

+
+ prime-tokens-question-icon +
+
+
+ , + "container":
+
+ prime-tokens-icon +

+ Prime Tokens Balance: $ + 0.00 +

+
+ prime-tokens-question-icon +
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/components/ReceiveModal.tsx b/src/apps/pillarx-app/components/ReceiveModal/ReceiveModal.tsx similarity index 90% rename from src/apps/pillarx-app/components/ReceiveModal.tsx rename to src/apps/pillarx-app/components/ReceiveModal/ReceiveModal.tsx index 31b730f5..f0b8eb56 100644 --- a/src/apps/pillarx-app/components/ReceiveModal.tsx +++ b/src/apps/pillarx-app/components/ReceiveModal/ReceiveModal.tsx @@ -5,19 +5,22 @@ import CopyToClipboard from 'react-copy-to-clipboard'; import { MdCheck } from 'react-icons/md'; // utils -import { CompatibleChains, getLogoForChainId } from '../../../utils/blockchain'; +import { + CompatibleChains, + getLogoForChainId, +} from '../../../../utils/blockchain'; // reducer -import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; -import { setIsReceiveModalOpen } from '../reducer/WalletPortfolioSlice'; +import { useAppDispatch, useAppSelector } from '../../hooks/useReducerHooks'; +import { setIsReceiveModalOpen } from '../../reducer/WalletPortfolioSlice'; // images -import CopyIcon from '../images/token-market-data-copy.png'; +import CopyIcon from '../../images/copy-icon.svg'; // components -import TokenLogoMarketDataRow from './TokenMarketDataRow/TokenLogoMarketDataRow'; -import Body from './Typography/Body'; -import BodySmall from './Typography/BodySmall'; +import TokenLogoMarketDataRow from '../TokenMarketDataRow/TokenLogoMarketDataRow'; +import Body from '../Typography/Body'; +import BodySmall from '../Typography/BodySmall'; const ReceiveModal = () => { const accountAddress = useWalletAddress(); diff --git a/src/apps/pillarx-app/components/ReceiveModal/test/ReceiveModal.test.tsx b/src/apps/pillarx-app/components/ReceiveModal/test/ReceiveModal.test.tsx new file mode 100644 index 00000000..0d8de830 --- /dev/null +++ b/src/apps/pillarx-app/components/ReceiveModal/test/ReceiveModal.test.tsx @@ -0,0 +1,116 @@ +import * as transactionKit from '@etherspot/transaction-kit'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// reducer +import * as reducerHooks from '../../../hooks/useReducerHooks'; +import * as walletSlice from '../../../reducer/WalletPortfolioSlice'; + +// components +import ReceiveModal from '../ReceiveModal'; + +jest.mock('../../../../../utils/blockchain', () => { + const original = jest.requireActual('../../../../../utils/blockchain'); + return { + ...original, + CompatibleChains: [ + { chainId: 1, chainName: 'Ethereum' }, + { chainId: 137, chainName: 'Polygon' }, + ], + getLogoForChainId: jest.fn(() => 'mocked-logo-url'), + }; +}); + +jest.mock('../../../hooks/useReducerHooks'); +jest.mock('@etherspot/transaction-kit', () => ({ + useWalletAddress: jest.fn(), +})); + +describe('', () => { + const useAppSelectorMock = + reducerHooks.useAppSelector as unknown as jest.Mock; + const useAppDispatchMock = + reducerHooks.useAppDispatch as unknown as jest.Mock; + + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + useAppDispatchMock.mockReturnValue(mockDispatch); + }); + + it('renders correctly and matches snapshot', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); + + it('does not render if isReceiveModalOpen is false', () => { + useAppSelectorMock.mockImplementation((cb) => + cb({ walletPortfolio: { isReceiveModalOpen: false } }) + ); + (transactionKit.useWalletAddress as jest.Mock).mockReturnValue('0x123'); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders modal when isReceiveModalOpen is true', () => { + useAppSelectorMock.mockImplementation((cb) => + cb({ walletPortfolio: { isReceiveModalOpen: true } }) + ); + (transactionKit.useWalletAddress as jest.Mock).mockReturnValue('0x123'); + + render(); + + expect(screen.getByText(/Receive/i)).toBeInTheDocument(); + expect(screen.getByText(/EVM Address/i)).toBeInTheDocument(); + expect(screen.getByText('0x123')).toBeInTheDocument(); + }); + + it('renders fallback text if no address is available', () => { + useAppSelectorMock.mockImplementation((cb) => + cb({ walletPortfolio: { isReceiveModalOpen: true } }) + ); + (transactionKit.useWalletAddress as jest.Mock).mockReturnValue(undefined); + + render(); + + expect( + screen.getByText(/We were not able to retrieve your EVM address/i) + ).toBeInTheDocument(); + }); + + it('closes modal when ESC button is clicked', () => { + useAppSelectorMock.mockImplementation((cb) => + cb({ walletPortfolio: { isReceiveModalOpen: true } }) + ); + (transactionKit.useWalletAddress as jest.Mock).mockReturnValue('0x123'); + + const setIsReceiveModalOpenSpy = jest.spyOn( + walletSlice, + 'setIsReceiveModalOpen' + ); + + render(); + + const escButton = screen.getByText('ESC'); + fireEvent.click(escButton); + + expect(setIsReceiveModalOpenSpy).toHaveBeenCalledWith(false); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'walletPortfolio/setIsReceiveModalOpen', + payload: false, + }); + }); + + it('renders supported chains', () => { + useAppSelectorMock.mockImplementation((cb) => + cb({ walletPortfolio: { isReceiveModalOpen: true } }) + ); + (transactionKit.useWalletAddress as jest.Mock).mockReturnValue('0xabc'); + + render(); + + expect(screen.getByText('Ethereum')).toBeInTheDocument(); + expect(screen.getByText('Polygon')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/pillarx-app/components/ReceiveModal/test/__snapshots__/ReceiveModal.test.tsx.snap b/src/apps/pillarx-app/components/ReceiveModal/test/__snapshots__/ReceiveModal.test.tsx.snap new file mode 100644 index 00000000..e880749c --- /dev/null +++ b/src/apps/pillarx-app/components/ReceiveModal/test/__snapshots__/ReceiveModal.test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/components/TileContainer/test/TileContainer.test.tsx b/src/apps/pillarx-app/components/TileContainer/test/TileContainer.test.tsx index d6f3d7f6..8fc3f0e2 100644 --- a/src/apps/pillarx-app/components/TileContainer/test/TileContainer.test.tsx +++ b/src/apps/pillarx-app/components/TileContainer/test/TileContainer.test.tsx @@ -66,7 +66,7 @@ describe('', () => { expect(tree.type).toBe('div'); expect(tree.props.className).toContain('flex'); expect(tree.props.className).toContain('bg-container_grey'); - expect(tree.props.className).toContain('rounded-2xl'); + expect(tree.props.className).toContain('rounded-3xl'); }); it('applies the custom class', () => { @@ -78,7 +78,7 @@ describe('', () => { .toJSON() as ReactTestRendererJSON; expect(tree.props.className).toContain('flex'); expect(tree.props.className).toContain('bg-container_grey'); - expect(tree.props.className).toContain('rounded-2xl'); + expect(tree.props.className).toContain('rounded-3xl'); expect(tree.props.className).toContain(customClass); }); }); diff --git a/src/apps/pillarx-app/components/TileContainer/test/__snapshots__/TileContainer.test.tsx.snap b/src/apps/pillarx-app/components/TileContainer/test/__snapshots__/TileContainer.test.tsx.snap index a26b90d4..17c73bf5 100644 --- a/src/apps/pillarx-app/components/TileContainer/test/__snapshots__/TileContainer.test.tsx.snap +++ b/src/apps/pillarx-app/components/TileContainer/test/__snapshots__/TileContainer.test.tsx.snap @@ -3,21 +3,21 @@ exports[` renders correctly and matches snapshot 1`] = ` [
Div as children
,
Div as children
,
First child diff --git a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/RightColumnTokenMarketDataRow.test.tsx b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/RightColumnTokenMarketDataRow.test.tsx index 72d8b3ce..ed43e684 100644 --- a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/RightColumnTokenMarketDataRow.test.tsx +++ b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/RightColumnTokenMarketDataRow.test.tsx @@ -41,7 +41,7 @@ describe(' - ETH token row', () => { it('renders percentage, price and transaction count ', () => { render(); - expect(screen.getByText('$0.042188')).toBeInTheDocument(); + expect(screen.getByText('$0.04219')).toBeInTheDocument(); // rounded up with limitDigitsNumber helper function expect(screen.getByText('20.1%')).toBeInTheDocument(); expect(screen.getByText(/Txs:/)).toBeInTheDocument(); expect(screen.getByText('1823')).toBeInTheDocument(); diff --git a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap index bf63f0fd..849d7fd7 100644 --- a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap +++ b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap @@ -41,7 +41,7 @@ exports[` - ETH token row renders and matches sn

- 17d ago + 29d ago

- ETH token row renders and matches sn

- 17d ago + 29d ago

- ETH token row renders and matches s class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

- ETH token row renders and matches s class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

displays loading skeleton when data is loading exports[` renders correctly and matches snapshot 1`] = `

renders correctly and matches snapshot 1`] = `

', () => { expect(mobileScreen.getByText('ETH')).toBeInTheDocument(); expect(mobileScreen.getByText('$1.2m')).toBeInTheDocument(); expect(mobileScreen.getByText('$30,123')).toBeInTheDocument(); - expect(mobileScreen.getByText('$0.042188')).toBeInTheDocument(); + expect(mobileScreen.getByText('$0.04219')).toBeInTheDocument(); // rounded up with limitDigitsNumber helper function expect(mobileScreen.getByText('20.1%')).toBeInTheDocument(); expect(mobileScreen.getByText('1823')).toBeInTheDocument(); expect(mobileScreen.getAllByText('XDAI')).toHaveLength(2); expect(mobileScreen.getByText('$1.4m')).toBeInTheDocument(); expect(mobileScreen.getByText('$3,123')).toBeInTheDocument(); - expect(mobileScreen.getByText('$1.062188')).toBeInTheDocument(); + expect(mobileScreen.getByText('$1.0622')).toBeInTheDocument(); // rounded up with limitDigitsNumber helper function expect(mobileScreen.getByText('3.1%')).toBeInTheDocument(); expect(mobileScreen.getByText('1423')).toBeInTheDocument(); }); diff --git a/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap b/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap index 90e0bbe1..b2a60cfb 100644 --- a/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap +++ b/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap @@ -6,7 +6,7 @@ exports[` renders and matches snapshot 1`] = ` "baseElement":

renders and matches snapshot 1`] = ` class="flex flex-col desktop:hidden" >
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

renders and matches snapshot 1`] = `
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal false desktop:text-percentage_red tablet:text-percentage_red mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 1.062188 + 1.0622

renders and matches snapshot 1`] = ` class="contents" >
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

renders and matches snapshot 1`] = `
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal false desktop:text-percentage_red tablet:text-percentage_red mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 1.062188 + 1.0622

renders and matches snapshot 1`] = ` , "container":
renders and matches snapshot 1`] = ` class="flex flex-col desktop:hidden" >
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

renders and matches snapshot 1`] = `
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal false desktop:text-percentage_red tablet:text-percentage_red mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 1.062188 + 1.0622

renders and matches snapshot 1`] = ` class="contents" >
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal desktop:text-market_row_green tablet:text-market_row_green false mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 0.042188 + 0.04219

renders and matches snapshot 1`] = `
renders and matches snapshot 1`] = `

- 17d ago + 29d ago

renders and matches snapshot 1`] = ` class="font-normal false desktop:text-percentage_red tablet:text-percentage_red mobile:text-white desktop:text-base tablet:text-base mobile:text-sm" > $ - 1.062188 + 1.0622

{ (state) => state.walletPortfolio.walletPortfolio as PortfolioData | undefined ); - const isWalletPorfolioLoading = useAppSelector( - (state) => state.walletPortfolio.isWalletPorfolioLoading as boolean + const isWalletPortfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioLoading as boolean ); const isWalletPortfolioErroring = useAppSelector( (state) => state.walletPortfolio.isWalletPortfolioErroring as boolean @@ -56,7 +56,7 @@ const TopTokens = () => {
Top Tokens
- {topTokens ? ( + {!isTopTokensEmpty ? (
{/* Header Row */}
@@ -83,7 +83,7 @@ const TopTokens = () => { chainLogo={getLogoForChainId( parseInt(token.contract.chainId.split(':')[1], 10) )} - tokenName="Token Name" + tokenName={token.asset.name || token.asset.symbol} size="w-9 h-9" chainLogoSize="w-[14px] h-[14px]" /> @@ -157,12 +157,12 @@ const TopTokens = () => {
) : (
- {isWalletPorfolioLoading || isTopTokenUnrealizedPnLLoading ? null : ( + {isWalletPortfolioLoading || isTopTokenUnrealizedPnLLoading ? null : (
Top Tokens
)}
{(isWalletPortfolioErroring || isTopTokenUnrealizedPnLErroring) && - (!isWalletPorfolioLoading || !isTopTokenUnrealizedPnLLoading) ? ( + (!isWalletPortfolioLoading || !isTopTokenUnrealizedPnLLoading) ? (
{isWalletPortfolioErroring && ( @@ -181,7 +181,7 @@ const TopTokens = () => { ) : null} {/* No tokens fallback */} - {!isWalletPorfolioLoading && + {!isWalletPortfolioLoading && !isTopTokenUnrealizedPnLLoading && !isWalletPortfolioErroring && !isTopTokenUnrealizedPnLErroring && diff --git a/src/apps/pillarx-app/components/TopTokens/test/TopTokens.test.tsx b/src/apps/pillarx-app/components/TopTokens/test/TopTokens.test.tsx new file mode 100644 index 00000000..b5add65d --- /dev/null +++ b/src/apps/pillarx-app/components/TopTokens/test/TopTokens.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from '@testing-library/react'; + +// services +import * as portfolioService from '../../../../../services/pillarXApiWalletPortfolio'; + +// reducer +import * as reducerHooks from '../../../hooks/useReducerHooks'; + +// components +import TopTokens from '../TopTokens'; + +jest.mock('../../../hooks/useReducerHooks'); +jest.mock('../../../../../services/pillarXApiWalletPortfolio'); + +const useAppSelectorMock = reducerHooks.useAppSelector as unknown as jest.Mock; + +describe('TopTokens component', () => { + const mockWalletPortfolio = { + accounts: [], + assets: [ + { + asset: { + symbol: 'USDC', + name: 'USD Coin', + logo: 'usdc-logo.png', + }, + contract: { + chainId: 'eip155:1', + }, + price: 1, + usdBalance: 200, + tokenBalance: 200, + unrealizedPnLUsd: 50, + unrealizedPnLPercentage: 10, + }, + { + asset: { + symbol: 'ETH', + name: 'Ethereum', + logo: 'eth-logo.png', + }, + contract: { + chainId: 'eip155:1', + }, + price: 2000, + usdBalance: 1000, + tokenBalance: 0.5, + unrealizedPnLUsd: -100, + unrealizedPnLPercentage: -9.5, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); + + it('renders nothing if loading', () => { + useAppSelectorMock.mockImplementation((selector) => + selector({ + walletPortfolio: { + walletPortfolio: undefined, + isWalletPortfolioLoading: true, + isWalletPortfolioErroring: false, + isTopTokenUnrealizedPnLLoading: true, + isTopTokenUnrealizedPnLErroring: false, + }, + }) + ); + + render(); + expect(screen.queryByText(/Unrealized PnL/i)).not.toBeInTheDocument(); + }); + + it('renders error messages if both data sources error', () => { + useAppSelectorMock.mockImplementation((selector) => + selector({ + walletPortfolio: { + walletPortfolio: undefined, + isWalletPortfolioLoading: false, + isWalletPortfolioErroring: true, + isTopTokenUnrealizedPnLLoading: false, + isTopTokenUnrealizedPnLErroring: true, + }, + }) + ); + + render(); + + expect( + screen.getByText(/Failed to load wallet portfolio/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/Failed to load unrealized PnL/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/Please check your internet connection/i) + ).toBeInTheDocument(); + }); + + it('renders "No tokens yet" when empty', () => { + useAppSelectorMock.mockImplementation((selector) => + selector({ + walletPortfolio: { + walletPortfolio: { + accounts: [], + assets: [], + }, + isWalletPortfolioLoading: false, + isWalletPortfolioErroring: false, + isTopTokenUnrealizedPnLLoading: false, + isTopTokenUnrealizedPnLErroring: false, + }, + }) + ); + + ( + portfolioService.getTopNonPrimeAssetsAcrossChains as jest.Mock + ).mockReturnValue([]); + + render(); + expect(screen.getByText(/No tokens yet/i)).toBeInTheDocument(); + }); + + it('renders top token data correctly', () => { + useAppSelectorMock.mockImplementation((selector) => + selector({ + walletPortfolio: { + walletPortfolio: mockWalletPortfolio, + isWalletPortfolioLoading: false, + isWalletPortfolioErroring: false, + isTopTokenUnrealizedPnLLoading: false, + isTopTokenUnrealizedPnLErroring: false, + }, + }) + ); + + ( + portfolioService.getTopNonPrimeAssetsAcrossChains as jest.Mock + ).mockReturnValue(mockWalletPortfolio.assets); + + render(); + + // Token info (without some of them because repeated in the DOM but hidden from user) + expect(screen.getByText('USDC')).toBeInTheDocument(); + expect(screen.getByText('USD Coin')).toBeInTheDocument(); + expect(screen.getByText('10.00%')).toBeInTheDocument(); + + expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByText('Ethereum')).toBeInTheDocument(); + expect(screen.getByText('9.50%')).toBeInTheDocument(); // negative shown as absolute + }); +}); diff --git a/src/apps/pillarx-app/components/TopTokens/test/__snapshots__/TopTokens.test.tsx.snap b/src/apps/pillarx-app/components/TopTokens/test/__snapshots__/TopTokens.test.tsx.snap new file mode 100644 index 00000000..606ea0cd --- /dev/null +++ b/src/apps/pillarx-app/components/TopTokens/test/__snapshots__/TopTokens.test.tsx.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopTokens component renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ Top Tokens +
+
+
+ Top Tokens +
+
+

+ No tokens yet +

+
+
+
+
+ , + "container":
+
+
+ Top Tokens +
+
+
+ Top Tokens +
+
+

+ No tokens yet +

+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/components/WalletConnectDropdown/test/__snapshots__/WalletConnectDropdown.test.tsx.snap b/src/apps/pillarx-app/components/WalletConnectDropdown/test/__snapshots__/WalletConnectDropdown.test.tsx.snap index 3ed5aab2..c74224d4 100644 --- a/src/apps/pillarx-app/components/WalletConnectDropdown/test/__snapshots__/WalletConnectDropdown.test.tsx.snap +++ b/src/apps/pillarx-app/components/WalletConnectDropdown/test/__snapshots__/WalletConnectDropdown.test.tsx.snap @@ -2,19 +2,19 @@ exports[` renders correctly and matches snapshot 1`] = `
wallet-connect-logo renders correctly and matches snapshot 1`] = } />

WalletConnect

-
-
- arrow-down + viewBox="0 0 24 24" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx b/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx index 5c020560..f6a88a97 100644 --- a/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx +++ b/src/apps/pillarx-app/components/WalletPortfolioBalance/WalletPortfolioBalance.tsx @@ -30,11 +30,11 @@ const WalletPortfolioBalance = () => { (state) => state.walletPortfolio.topTokenUnrealizedPnL as WalletHistory | undefined ); - const isWalletPorfolioLoading = useAppSelector( - (state) => state.walletPortfolio.isWalletPorfolioLoading as boolean + const isWalletPortfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioLoading as boolean ); - const isWalletPorfolioWithPnlLoading = useAppSelector( - (state) => state.walletPortfolio.isWalletPorfolioWithPnlLoading as boolean + const isWalletPortfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioWithPnlLoading as boolean ); const isWalletHistoryGraphLoading = useAppSelector( (state) => state.walletPortfolio.isWalletHistoryGraphLoading as boolean @@ -44,8 +44,8 @@ const WalletPortfolioBalance = () => { ); const isAnyDataFetching = - isWalletPorfolioLoading || - isWalletPorfolioWithPnlLoading || + isWalletPortfolioLoading || + isWalletPortfolioWithPnlLoading || isWalletHistoryGraphLoading || isTopTokenUnrealizedPnLLoading; @@ -88,7 +88,7 @@ const WalletPortfolioBalance = () => { /> My portfolio
- {isWalletPorfolioLoading || !walletPortfolio ? ( + {isWalletPortfolioLoading || !walletPortfolio ? ( ) : (
@@ -101,14 +101,14 @@ const WalletPortfolioBalance = () => { : '0.00'}

- {!balanceChange || !balanceChange?.usdValue ? null : ( + {!balanceChange ? null : ( 0 && 'text-market_row_green'} ${balanceChange.usdValue < 0 && 'text-percentage_red'} ${getUsdChangeText() === '$0.00' && 'text-white text-opacity-50'} ${balanceChange.usdValue === 0 && 'text-white text-opacity-50 bg-white/[.1]'}`} > {getUsdChangeText()} )} - {!balanceChange || !balanceChange?.percentageValue ? null : ( + {!balanceChange ? null : (
0 && 'text-market_row_green bg-market_row_green/[.1]'} ${balanceChange.percentageValue < 0 && 'text-percentage_red bg-percentage_red/[.1]'} ${balanceChange.percentageValue === 0 && 'text-white text-opacity-50 bg-white/[.1]'}`} > @@ -137,7 +137,7 @@ const WalletPortfolioBalance = () => {
)} - {!balanceChange || !balanceChange.percentageValue ? null : ( + {!balanceChange ? null : ( diff --git a/src/apps/pillarx-app/components/WalletPortfolioBalance/test/WalletPortfolioBalance.test.tsx b/src/apps/pillarx-app/components/WalletPortfolioBalance/test/WalletPortfolioBalance.test.tsx new file mode 100644 index 00000000..b7e089d0 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioBalance/test/WalletPortfolioBalance.test.tsx @@ -0,0 +1,166 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react'; + +// reducer +import { + useAppDispatch as useAppDispatchMock, + useAppSelector as useAppSelectorMock, +} from '../../../hooks/useReducerHooks'; +import { setIsRefreshAll } from '../../../reducer/WalletPortfolioSlice'; + +// components +import WalletPortfolioBalance from '../WalletPortfolioBalance'; + +jest.mock('../../../hooks/useReducerHooks'); +jest.mock('../../../../../components/SkeletonLoader', () => ({ + __esModule: true, + default: function SkeletonLoader() { + return
Loading...
; + }, +})); +jest.mock('../../../images/refresh-button.png', () => 'refresh-icon.png'); +jest.mock( + '../../../images/wallet-portfolio-icon.png', + () => 'wallet-portfolio-icon.png' +); + +const mockDispatch = jest.fn(); +(useAppDispatchMock as unknown as jest.Mock).mockReturnValue(mockDispatch); + +describe('WalletPortfolioBalance', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseSelectorState = { + walletPortfolio: { + isWalletPortfolioLoading: false, + isWalletPortfolioWithPnlLoading: false, + isWalletHistoryGraphLoading: false, + isTopTokenUnrealizedPnLLoading: false, + }, + }; + + const renderWithSelector = ({ + walletPortfolio, + topTokenUnrealizedPnL, + overrides = {}, + }: { + walletPortfolio?: any; + topTokenUnrealizedPnL?: any; + overrides?: Partial<(typeof baseSelectorState)['walletPortfolio']>; + }) => { + (useAppSelectorMock as unknown as jest.Mock).mockImplementation( + (selectorFn) => + selectorFn({ + walletPortfolio: { + walletPortfolio, + topTokenUnrealizedPnL, + ...baseSelectorState.walletPortfolio, + ...overrides, + }, + }) + ); + + render(); + }; + + it('renders correctly and matches snapshot', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); + + it('shows skeleton when loading', () => { + renderWithSelector({ + walletPortfolio: undefined, + overrides: { isWalletPortfolioLoading: true }, + }); + + expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument(); + }); + + it('renders 0 balance when wallet is empty', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 0 }, + topTokenUnrealizedPnL: undefined, + }); + + expect(screen.getByText('$0.00')).toBeInTheDocument(); + expect(screen.getByAltText('wallet-portfolio-icon')).toBeInTheDocument(); + }); + + it('renders balance with positive change', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 1000 }, + topTokenUnrealizedPnL: { + balance_history: [ + [1, 500], + [2, 1000], + ], + }, + }); + + expect(screen.getByText('$1000')).toBeInTheDocument(); + expect(screen.getByText(/\+?\$500/)).toBeInTheDocument(); + expect(screen.getByText('100.00%')).toBeInTheDocument(); + expect(screen.getByText('24h')).toBeInTheDocument(); + }); + + it('renders balance with negative change', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 1000 }, + topTokenUnrealizedPnL: { + balance_history: [ + [1, 1000], + [2, 500], + ], + }, + }); + + expect(screen.getByText('-$500')).toBeInTheDocument(); + expect(screen.getByText('50.00%')).toBeInTheDocument(); + }); + + it('renders balance with zero change', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 1000 }, + topTokenUnrealizedPnL: { + balance_history: [ + [1, 1000], + [2, 1000], + ], + }, + }); + + expect(screen.queryByText(/\$500/)).not.toBeInTheDocument(); + expect(screen.queryByText(/100.00%/)).not.toBeInTheDocument(); + expect(screen.queryByText('0.00%')).toBeInTheDocument(); + expect(screen.getByText('24h')).toBeInTheDocument(); + }); + + it('disables refresh button while loading data', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 1000 }, + overrides: { + isWalletPortfolioLoading: true, + isWalletPortfolioWithPnlLoading: true, + }, + }); + + const button = screen.getByAltText('refresh-button').parentElement; + expect(button).toHaveClass('opacity-50'); + fireEvent.click(button!); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('enables refresh button and dispatches action', () => { + renderWithSelector({ + walletPortfolio: { total_wallet_balance: 1000 }, + }); + + const button = screen.getByAltText('refresh-button').parentElement; + expect(button).not.toHaveClass('opacity-50'); + fireEvent.click(button!); + expect(mockDispatch).toHaveBeenCalledWith(setIsRefreshAll(true)); + }); +}); diff --git a/src/apps/pillarx-app/components/WalletPortfolioBalance/test/__snapshots__/WalletPortfolioBalance.test.tsx.snap b/src/apps/pillarx-app/components/WalletPortfolioBalance/test/__snapshots__/WalletPortfolioBalance.test.tsx.snap new file mode 100644 index 00000000..a621040d --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioBalance/test/__snapshots__/WalletPortfolioBalance.test.tsx.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WalletPortfolioBalance renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+ wallet-portfolio-icon +

+ My portfolio +

+
+
+ Loading... +
+
+
+ refresh-button +
+
+
+ , + "container":
+
+
+
+ wallet-portfolio-icon +

+ My portfolio +

+
+
+ Loading... +
+
+
+ refresh-button +
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx b/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx index 34fb1182..39d0a4a1 100644 --- a/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx +++ b/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx @@ -5,7 +5,7 @@ import { useAppDispatch } from '../../hooks/useReducerHooks'; import { setIsReceiveModalOpen } from '../../reducer/WalletPortfolioSlice'; // components -import ReceiveModal from '../ReceiveModal'; +import ReceiveModal from '../ReceiveModal/ReceiveModal'; import BodySmall from '../Typography/BodySmall'; import WalletConnectDropdown from '../WalletConnectDropdown/WalletConnectDropdown'; @@ -14,7 +14,8 @@ const WalletPortfolioButtons = () => { return (
-
dispatch(setIsReceiveModalOpen(true))} > @@ -22,7 +23,7 @@ const WalletPortfolioButtons = () => { Receive
-
+
); diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx index 3101b0ee..36561289 100644 --- a/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx @@ -94,8 +94,8 @@ const BalancePnlGraph = () => { (state) => state.walletPortfolio.walletPortfolioWithPnl as PortfolioData | undefined ); - const isWalletPorfolioWithPnlLoading = useAppSelector( - (state) => state.walletPortfolio.isWalletPorfolioWithPnlLoading as boolean + const isWalletPortfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioWithPnlLoading as boolean ); const isWalletPortfolioWithPnlErroring = useAppSelector( (state) => state.walletPortfolio.isWalletPortfolioWithPnlErroring as boolean @@ -194,7 +194,7 @@ const BalancePnlGraph = () => { isBalanceGraph, periodFilter, periodFilterPnl, - walletHistoryGraph?.balance_history.length, + walletHistoryGraph?.balance_history?.length, walletPortfolioWithPnl?.total_pnl_history, ]); @@ -425,8 +425,10 @@ const BalancePnlGraph = () => { ); } - if (selectedBalanceOrPnl === 'pnl' && isWalletPorfolioWithPnlLoading) { -
; + if (selectedBalanceOrPnl === 'pnl' && isWalletPortfolioWithPnlLoading) { + return ( +
+ ); } if (selectedBalanceOrPnl === 'balance' && isWalletHistoryGraphErroring) { diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx index 9226a9fc..bb579584 100644 --- a/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx @@ -13,12 +13,13 @@ const WalletPortfolioGraphButton = ({ onClick, }: WalletPortfolioGraphButtonProps) => { return ( -
{text} -
+ ); }; diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/WalletPortfolioGraph.test.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/WalletPortfolioGraph.test.tsx new file mode 100644 index 00000000..da57a2c4 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/WalletPortfolioGraph.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +// reducer +import { + useAppDispatch as useAppDispatchMock, + useAppSelector as useAppSelectorMock, +} from '../../../hooks/useReducerHooks'; +import { + setPeriodFilter, + setPeriodFilterPnl, + setSelectedBalanceOrPnl, +} from '../../../reducer/WalletPortfolioSlice'; + +// utils +import { PeriodFilterBalance, PeriodFilterPnl } from '../../../utils/portfolio'; + +// components +import WalletPortfolioGraph from '../WalletPortfolioGraph'; + +jest.mock('../../../hooks/useReducerHooks'); +jest.mock('../../Typography/BodySmall', () => ({ + __esModule: true, + default: function BodySmall({ children }: { children: React.ReactNode }) { + return
{children}
; + }, +})); + +jest.mock('../BalancePnlGraph', () => ({ + __esModule: true, + default: function BalancePnlGraph() { + return
Graph Component
; + }, +})); + +jest.mock('../WalletPortfolioGraphButton', () => ({ + __esModule: true, + default: function WalletPortfolioGraphButton({ + text, + isActive, + onClick, + }: { + text: string; + isActive?: boolean; + onClick: () => void; + }) { + return ( + // eslint-disable-next-line react/button-has-type + + ); + }, +})); + +const mockDispatch = jest.fn(); +(useAppDispatchMock as unknown as jest.Mock).mockReturnValue(mockDispatch); + +describe('WalletPortfolioGraph', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderWithState = (selectedBalanceOrPnl: 'balance' | 'pnl') => { + (useAppSelectorMock as unknown as jest.Mock).mockImplementation( + (selectorFn) => + selectorFn({ + walletPortfolio: { + periodFilter: PeriodFilterBalance.WEEK, + periodFilterPnl: PeriodFilterPnl.MONTH, + selectedBalanceOrPnl, + }, + }) + ); + render(); + }; + + it('renders correctly and matches snapshot', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); + + it('dispatches default DAY filter on mount', () => { + renderWithState('balance'); + + expect(mockDispatch).toHaveBeenCalledWith( + setPeriodFilter(PeriodFilterBalance.DAY) + ); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'walletPortfolio/setPriceGraphPeriod', + }) + ); + }); + + it('renders balance time filters and Balance✓ is active', () => { + renderWithState('balance'); + + expect(screen.getByTestId('btn-1h')).toBeInTheDocument(); + expect(screen.getByTestId('btn-1mo')).toBeInTheDocument(); + expect(screen.getByTestId('btn-1w')).toBeInTheDocument(); + expect(screen.getByTestId('btn-6mo')).toBeInTheDocument(); + + expect(screen.getByTestId('btn-Balance')).toHaveTextContent('Balance ✓'); + expect(screen.getByTestId('btn-PnL')).toHaveTextContent('PnL'); + }); + + it('renders pnl time filters and PnL✓ is active', () => { + renderWithState('pnl'); + + expect(screen.getByTestId('btn-24h')).toBeInTheDocument(); + expect(screen.getByTestId('btn-1y')).toBeInTheDocument(); + + expect(screen.getByTestId('btn-PnL')).toHaveTextContent('PnL ✓'); + expect(screen.getByTestId('btn-Balance')).toHaveTextContent('Balance'); + }); + + it('clicking balance period filter dispatches correct actions', () => { + renderWithState('balance'); + + fireEvent.click(screen.getByTestId('btn-1w')); + expect(mockDispatch).toHaveBeenCalledWith( + setPeriodFilter(PeriodFilterBalance.WEEK) + ); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'walletPortfolio/setPriceGraphPeriod', + }) + ); + }); + + it('clicking pnl period filter dispatches only setPeriodFilterPnl', () => { + renderWithState('pnl'); + + fireEvent.click(screen.getByTestId('btn-1mo')); + expect(mockDispatch).toHaveBeenCalledWith( + setPeriodFilterPnl(PeriodFilterPnl.MONTH) + ); + }); + + it('clicking PnL/Bal toggle updates selectedBalanceOrPnl', () => { + renderWithState('balance'); + + fireEvent.click(screen.getByTestId('btn-PnL')); + expect(mockDispatch).toHaveBeenCalledWith(setSelectedBalanceOrPnl('pnl')); + + fireEvent.click(screen.getByTestId('btn-Balance')); + expect(mockDispatch).toHaveBeenCalledWith( + setSelectedBalanceOrPnl('balance') + ); + }); + + it('renders the BalancePnlGraph component', () => { + renderWithState('balance'); + expect(screen.getByTestId('balance-graph')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/__snapshots__/WalletPortfolioGraph.test.tsx.snap b/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/__snapshots__/WalletPortfolioGraph.test.tsx.snap new file mode 100644 index 00000000..088fbba6 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/tests/__snapshots__/WalletPortfolioGraph.test.tsx.snap @@ -0,0 +1,218 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WalletPortfolioGraph renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ Graph Component +
+
+
+ , + "container":
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ Graph Component +
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx b/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx index a887ad25..4643c1fc 100644 --- a/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx +++ b/src/apps/pillarx-app/components/WalletPortfolioTile/WalletPortfolioTile.tsx @@ -27,8 +27,8 @@ import { setIsTopTokenUnrealizedPnLLoading, setIsWalletHistoryGraphErroring, setIsWalletHistoryGraphLoading, - setIsWalletPorfolioLoading, setIsWalletPortfolioErroring, + setIsWalletPortfolioLoading, setIsWalletPortfolioWithPnlErroring, setIsWalletPortfolioWithPnlLoading, setTopTokenUnrealizedPnL, @@ -145,7 +145,7 @@ const WalletPortfolioTile = () => { isWalletPortfolioDataSuccess, walletPortfolioDataError, setWalletPortfolio, - setIsWalletPorfolioLoading, + setIsWalletPortfolioLoading, setIsWalletPortfolioErroring ); diff --git a/src/apps/pillarx-app/components/WalletPortfolioTile/test/WalletPortfolioTile.test.tsx b/src/apps/pillarx-app/components/WalletPortfolioTile/test/WalletPortfolioTile.test.tsx new file mode 100644 index 00000000..ec873286 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioTile/test/WalletPortfolioTile.test.tsx @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as transactionKit from '@etherspot/transaction-kit'; +import { render, screen } from '@testing-library/react'; + +// servuces +import * as historyHooks from '../../../../../services/pillarXApiWalletHistory'; +import * as apiHooks from '../../../../../services/pillarXApiWalletPortfolio'; + +// hooks +import * as fetchStateHook from '../../../hooks/useDataFetchingState'; + +// reducer +import * as reduxHooks from '../../../hooks/useReducerHooks'; +import * as sliceActions from '../../../reducer/WalletPortfolioSlice'; + +// components +import WalletPortfolioTile from '../WalletPortfolioTile'; + +// Mock child components to isolate the tile itself +jest.mock('../../WalletPortfolioBalance/WalletPortfolioBalance', () => ({ + __esModule: true, + default: function WalletPortfolioBalance() { + return
WalletPortfolioBalance
; + }, +})); +jest.mock('../../WalletPortfolioButtons/WalletPortfolioButtons', () => ({ + __esModule: true, + default: function WalletPortfolioButtons() { + return
WalletPortfolioButtons
; + }, +})); +jest.mock('../../PrimeTokensBalance/PrimeTokensBalance', () => ({ + __esModule: true, + default: function PrimeTokensBalance() { + return
PrimeTokensBalance
; + }, +})); +jest.mock('../../WalletPortfolioGraph/WalletPortfolioGraph', () => ({ + __esModule: true, + default: function WalletPortfolioGraph() { + return
WalletPortfolioGraph
; + }, +})); +jest.mock('../../TopTokens/TopTokens', () => ({ + __esModule: true, + default: function TopTokens() { + return
TopTokens
; + }, +})); + +describe('', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Redux hooks + jest.spyOn(reduxHooks, 'useAppDispatch').mockReturnValue(dispatch); + jest + .spyOn(reduxHooks, 'useAppSelector') + .mockImplementation((selectorFn: any) => + selectorFn({ + walletPortfolio: { + priceGraphPeriod: { from: 1111 }, + periodFilter: '24h', + selectedBalanceOrPnl: 'balance', + isRefreshAll: false, + }, + }) + ); + + jest.spyOn(transactionKit, 'useWalletAddress').mockReturnValue('0x1234'); + + // Mock useDataFetchingState + jest + .spyOn(fetchStateHook, 'useDataFetchingState') + .mockImplementation(() => {}); + + // Mock queries + jest + .spyOn(apiHooks, 'useGetWalletPortfolioQuery') + .mockImplementation((args: any) => { + if (args.isPnl) { + return { + data: { result: { data: {} } }, + isLoading: false, + isFetching: false, + isSuccess: true, + error: null, + refetch: jest.fn(), + }; + } + return { + data: { result: { data: {} } }, + isLoading: false, + isFetching: false, + isSuccess: true, + error: null, + refetch: jest.fn(), + }; + }); + + jest.spyOn(historyHooks, 'useGetWalletHistoryQuery').mockReturnValue({ + data: { result: { data: {} } }, + isLoading: false, + isFetching: false, + isSuccess: true, + error: null, + refetch: jest.fn(), + }); + }); + + it('renders correctly and matches snapshot', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); + + it('renders all tile sections correctly', () => { + render(); + + expect(screen.getByText('WalletPortfolioBalance')).toBeInTheDocument(); + expect(screen.getAllByText('WalletPortfolioButtons')).toHaveLength(2); + expect(screen.getByText('PrimeTokensBalance')).toBeInTheDocument(); + expect(screen.getAllByText('WalletPortfolioGraph')).toHaveLength(2); + expect(screen.getByText('TopTokens')).toBeInTheDocument(); + }); + + it('skips rendering parts if no wallet address', () => { + jest.spyOn(transactionKit, 'useWalletAddress').mockReturnValue(undefined); + + render(); + + expect(screen.getByText('WalletPortfolioBalance')).toBeInTheDocument(); + }); + + it('triggers refetch on isRefreshAll true and dispatch reset after timeout', () => { + jest.useFakeTimers(); + + const refetchMock = jest.fn(); + jest + .spyOn(reduxHooks, 'useAppSelector') + .mockImplementation((selectorFn: any) => + selectorFn({ + walletPortfolio: { + priceGraphPeriod: { from: 1111 }, + periodFilter: '24h', + selectedBalanceOrPnl: 'pnl', + isRefreshAll: true, + }, + }) + ); + + jest + .spyOn(apiHooks, 'useGetWalletPortfolioQuery') + .mockImplementation(() => { + return { + data: { result: { data: {} } }, + isLoading: false, + isFetching: false, + isSuccess: true, + error: null, + refetch: refetchMock, + }; + }); + + jest.spyOn(historyHooks, 'useGetWalletHistoryQuery').mockReturnValue({ + data: { result: { data: {} } }, + isLoading: false, + isFetching: false, + isSuccess: true, + error: null, + refetch: refetchMock, + }); + + render(); + expect(refetchMock).toHaveBeenCalledTimes(4); + + jest.advanceTimersByTime(5000); + expect(dispatch).toHaveBeenCalledWith(sliceActions.setIsRefreshAll(false)); + + jest.useRealTimers(); + }); +}); diff --git a/src/apps/pillarx-app/components/WalletPortfolioTile/test/__snapshots__/WalletPortfolioTile.test.tsx.snap b/src/apps/pillarx-app/components/WalletPortfolioTile/test/__snapshots__/WalletPortfolioTile.test.tsx.snap new file mode 100644 index 00000000..92ac802d --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioTile/test/__snapshots__/WalletPortfolioTile.test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly and matches snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+ WalletPortfolioBalance +
+
+
+ WalletPortfolioButtons +
+
+
+ PrimeTokensBalance +
+
+
+ WalletPortfolioGraph +
+
+
+
+ WalletPortfolioButtons +
+
+
+ TopTokens +
+
+
+
+ WalletPortfolioGraph +
+
+
+
+ , + "container":
+
+
+
+ WalletPortfolioBalance +
+
+
+ WalletPortfolioButtons +
+
+
+ PrimeTokensBalance +
+
+
+ WalletPortfolioGraph +
+
+
+
+ WalletPortfolioButtons +
+
+
+ TopTokens +
+
+
+
+ WalletPortfolioGraph +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts b/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts index 747819a8..0c53968f 100644 --- a/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts +++ b/src/apps/pillarx-app/reducer/WalletPortfolioSlice.ts @@ -15,10 +15,10 @@ import { PeriodFilterBalance, PeriodFilterPnl } from '../utils/portfolio'; export type WalletPortfolioState = { walletPortfolio: PortfolioData | undefined; - isWalletPorfolioLoading: boolean; + isWalletPortfolioLoading: boolean; isWalletPortfolioErroring: boolean; walletPortfolioWithPnl: PortfolioData | undefined; - isWalletPorfolioWithPnlLoading: boolean; + isWalletPortfolioWithPnlLoading: boolean; isWalletPortfolioWithPnlErroring: boolean; priceGraphPeriod: TokenPriceGraphPeriod; periodFilter: PeriodFilterBalance; @@ -36,10 +36,10 @@ export type WalletPortfolioState = { const initialState: WalletPortfolioState = { walletPortfolio: undefined, - isWalletPorfolioLoading: false, + isWalletPortfolioLoading: false, isWalletPortfolioErroring: false, walletPortfolioWithPnl: undefined, - isWalletPorfolioWithPnlLoading: false, + isWalletPortfolioWithPnlLoading: false, isWalletPortfolioWithPnlErroring: false, priceGraphPeriod: { from: convertDateToUnixTimestamp(sub(new Date(), { days: 1 })), @@ -68,8 +68,8 @@ const walletPortfolioSlice = createSlice({ ) { state.walletPortfolio = action.payload; }, - setIsWalletPorfolioLoading(state, action: PayloadAction) { - state.isWalletPorfolioLoading = action.payload; + setIsWalletPortfolioLoading(state, action: PayloadAction) { + state.isWalletPortfolioLoading = action.payload; }, setIsWalletPortfolioErroring(state, action: PayloadAction) { state.isWalletPortfolioErroring = action.payload; @@ -81,7 +81,7 @@ const walletPortfolioSlice = createSlice({ state.walletPortfolioWithPnl = action.payload; }, setIsWalletPortfolioWithPnlLoading(state, action: PayloadAction) { - state.isWalletPorfolioWithPnlLoading = action.payload; + state.isWalletPortfolioWithPnlLoading = action.payload; }, setIsWalletPortfolioWithPnlErroring(state, action: PayloadAction) { state.isWalletPortfolioWithPnlErroring = action.payload; @@ -133,7 +133,7 @@ const walletPortfolioSlice = createSlice({ export const { setWalletPortfolio, - setIsWalletPorfolioLoading, + setIsWalletPortfolioLoading, setIsWalletPortfolioErroring, setWalletPortfolioWithPnl, setIsWalletPortfolioWithPnlLoading, diff --git a/src/services/pillarXApiWalletPortfolio.ts b/src/services/pillarXApiWalletPortfolio.ts index dba19e87..ee1270a7 100644 --- a/src/services/pillarXApiWalletPortfolio.ts +++ b/src/services/pillarXApiWalletPortfolio.ts @@ -100,7 +100,7 @@ export const getTopNonPrimeAssetsAcrossChains = ( .flatMap((assetData) => assetData.contracts_balances.map((contract) => { const usdBalance = contract.balance * assetData.price; - const priceChangePercent = assetData.price_change_24h; + const priceChangePercent = assetData.price_change_24h ?? 0; const previousBalance = priceChangePercent === -100 diff --git a/src/utils/number.tsx b/src/utils/number.tsx index 6321d141..16be9402 100644 --- a/src/utils/number.tsx +++ b/src/utils/number.tsx @@ -1,4 +1,4 @@ -import { parseInt } from 'lodash'; +import { parseInt as parseIntLodash } from 'lodash'; export const formatAmountDisplay = ( amountRaw: string | number, @@ -48,8 +48,8 @@ export const limitDigitsNumber = (num: number): number => { const [integerPart, fractionalPart] = numStr.split('.'); // If integer part is greater than 0 it will show between 2 and 4 decimals - if (parseInt(integerPart) > 0) { - if (parseInt(integerPart) >= 1000) { + if (parseIntLodash(integerPart) > 0) { + if (parseIntLodash(integerPart) >= 1000) { return Number(num.toFixed(2)); } return Number(num.toFixed(4));