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/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/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/ReceiveModal.tsx b/src/apps/pillarx-app/components/ReceiveModal/ReceiveModal.tsx new file mode 100644 index 00000000..f0b8eb56 --- /dev/null +++ b/src/apps/pillarx-app/components/ReceiveModal/ReceiveModal.tsx @@ -0,0 +1,125 @@ +/* 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/copy-icon.svg'; + +// 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/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/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/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/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 - 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

{ + const walletPortfolio = useAppSelector( + (state) => + state.walletPortfolio.walletPortfolio as PortfolioData | undefined + ); + const isWalletPortfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioLoading 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 +
+ {!isTopTokensEmpty ? ( +
+ {/* 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)}% +

+
+
+
+ ))} +
+ ) : ( +
+ {isWalletPortfolioLoading || isTopTokenUnrealizedPnLLoading ? null : ( +
Top Tokens
+ )} +
+ {(isWalletPortfolioErroring || isTopTokenUnrealizedPnLErroring) && + (!isWalletPortfolioLoading || !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 */} + {!isWalletPortfolioLoading && + !isTopTokenUnrealizedPnLLoading && + !isWalletPortfolioErroring && + !isTopTokenUnrealizedPnLErroring && + isTopTokensEmpty && ( + + No tokens yet + + )} +
+
+ )} +
+ ); +}; + +export default TopTokens; 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/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/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 new file mode 100644 index 00000000..f6a88a97 --- /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 isWalletPortfolioLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioLoading as boolean + ); + const isWalletPortfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioWithPnlLoading as boolean + ); + const isWalletHistoryGraphLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletHistoryGraphLoading as boolean + ); + const isTopTokenUnrealizedPnLLoading = useAppSelector( + (state) => state.walletPortfolio.isTopTokenUnrealizedPnLLoading as boolean + ); + + const isAnyDataFetching = + isWalletPortfolioLoading || + isWalletPortfolioWithPnlLoading || + 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 +
+ {isWalletPortfolioLoading || !walletPortfolio ? ( + + ) : ( +
+

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

+
+ {!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 ? 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 ? null : ( + + 24h + + )} +
+
+ )} +
+
{ + if (!isAnyDataFetching) { + dispatch(setIsRefreshAll(true)); + } + }} + > + refresh-button +
+
+ ); +}; + +export default WalletPortfolioBalance; 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 new file mode 100644 index 00000000..39d0a4a1 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioButtons/WalletPortfolioButtons.tsx @@ -0,0 +1,32 @@ +import { RiArrowDownLine } from 'react-icons/ri'; + +// reducer +import { useAppDispatch } from '../../hooks/useReducerHooks'; +import { setIsReceiveModalOpen } from '../../reducer/WalletPortfolioSlice'; + +// components +import ReceiveModal from '../ReceiveModal/ReceiveModal'; +import BodySmall from '../Typography/BodySmall'; +import WalletConnectDropdown from '../WalletConnectDropdown/WalletConnectDropdown'; + +const WalletPortfolioButtons = () => { + const dispatch = useAppDispatch(); + return ( +
+ + + +
+ ); +}; + +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..36561289 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx @@ -0,0 +1,476 @@ +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 isWalletPortfolioWithPnlLoading = useAppSelector( + (state) => state.walletPortfolio.isWalletPortfolioWithPnlLoading 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' && isWalletPortfolioWithPnlLoading) { + return ( +
+ ); + } + + 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..bb579584 --- /dev/null +++ b/src/apps/pillarx-app/components/WalletPortfolioGraph/WalletPortfolioGraphButton.tsx @@ -0,0 +1,26 @@ +// components +import BodySmall from '../Typography/BodySmall'; + +type WalletPortfolioGraphButtonProps = { + isActive: boolean; + text: string; + onClick: () => void; +}; + +const WalletPortfolioGraphButton = ({ + isActive, + text, + onClick, +}: WalletPortfolioGraphButtonProps) => { + return ( + + ); +}; + +export default WalletPortfolioGraphButton; 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 new file mode 100644 index 00000000..4643c1fc --- /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, + setIsWalletPortfolioErroring, + setIsWalletPortfolioLoading, + 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, + setIsWalletPortfolioLoading, + 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/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/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 00000000..3087af28 Binary files /dev/null and b/src/apps/pillarx-app/images/prime-tokens-icon.png differ 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 00000000..72d049ee Binary files /dev/null and b/src/apps/pillarx-app/images/prime-tokens-question-icon.png differ diff --git a/src/apps/pillarx-app/images/refresh-button.png b/src/apps/pillarx-app/images/refresh-button.png new file mode 100644 index 00000000..2f40e4fb Binary files /dev/null and b/src/apps/pillarx-app/images/refresh-button.png differ diff --git a/src/apps/pillarx-app/images/wallet-portfolio-icon.png b/src/apps/pillarx-app/images/wallet-portfolio-icon.png new file mode 100644 index 00000000..efb92e12 Binary files /dev/null and b/src/apps/pillarx-app/images/wallet-portfolio-icon.png differ diff --git a/src/apps/pillarx-app/index.tsx b/src/apps/pillarx-app/index.tsx index f25ad361..ee4d0bb7 100644 --- a/src/apps/pillarx-app/index.tsx +++ b/src/apps/pillarx-app/index.tsx @@ -8,16 +8,12 @@ import styled from 'styled-components'; import './styles/tailwindPillarX.css'; // types -import { Projection, WalletData } from '../../types/api'; +import { Projection } from '../../types/api'; // hooks import { useRecordPresenceMutation } from '../../services/pillarXApiPresence'; import { useGetWaitlistQuery } from '../../services/pillarXApiWaitlist'; -import { - useGetTilesInfoQuery, - useGetWalletInfoQuery, - useRecordProfileMutation, -} from './api/homeFeed'; +import { useGetTilesInfoQuery, useRecordProfileMutation } from './api/homeFeed'; import useRefDimensions from './hooks/useRefDimensions'; // utils @@ -25,10 +21,10 @@ import { componentMap } from './utils/configComponent'; // components import AnimatedTile from './components/AnimatedTile/AnimatedTitle'; -import PortfolioOverview from './components/PortfolioOverview/PortfolioOverview'; import SkeletonTiles from './components/SkeletonTile/SkeletonTile'; import Body from './components/Typography/Body'; import H1 from './components/Typography/H1'; +import WalletPortfolioTile from './components/WalletPortfolioTile/WalletPortfolioTile'; // images import PillarXLogo from './components/PillarXLogo/PillarXLogo'; @@ -42,9 +38,6 @@ const App = () => { const [page, setPage] = useState(1); const [isLoadingNextPage, setIsLoadingNextPage] = useState(false); const [pageData, setPageData] = useState([]); - const [walletData, setWalletData] = useState( - undefined - ); // Import wallets const walletAddress = useWalletAddress(); @@ -79,16 +72,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, @@ -115,22 +98,6 @@ const App = () => { } }, [walletAddress, privyWallets, recordProfile]); - // 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(); @@ -245,10 +212,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..0c53968f --- /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; + isWalletPortfolioLoading: boolean; + isWalletPortfolioErroring: boolean; + walletPortfolioWithPnl: PortfolioData | undefined; + isWalletPortfolioWithPnlLoading: 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, + isWalletPortfolioLoading: false, + isWalletPortfolioErroring: false, + walletPortfolioWithPnl: undefined, + isWalletPortfolioWithPnlLoading: 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; + }, + setIsWalletPortfolioLoading(state, action: PayloadAction) { + state.isWalletPortfolioLoading = action.payload; + }, + setIsWalletPortfolioErroring(state, action: PayloadAction) { + state.isWalletPortfolioErroring = action.payload; + }, + setWalletPortfolioWithPnl( + state, + action: PayloadAction + ) { + state.walletPortfolioWithPnl = action.payload; + }, + setIsWalletPortfolioWithPnlLoading(state, action: PayloadAction) { + state.isWalletPortfolioWithPnlLoading = 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, + setIsWalletPortfolioLoading, + 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..ee1270a7 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 ?? 0; + + 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..16be9402 100644 --- a/src/utils/number.tsx +++ b/src/utils/number.tsx @@ -1,3 +1,5 @@ +import { parseInt as parseIntLodash } 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 (parseIntLodash(integerPart) > 0) { + if (parseIntLodash(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)); +};