`;
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`] = `
renders correctly and matches snapshot 1`] = `
}
/>
WalletConnect
-
-
-
+ 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 Balance: $
+ {primeAssetsBalance && primeAssetsBalance > 0
+ ? primeAssetsBalance
+ : '0.00'}
+
+
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+ {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 Balance: $
+ 0.00
+
+
+
+
+
+
+ ,
+ "container":
+
+
+
+ Prime Tokens Balance: $
+ 0.00
+
+
+
+
+
+
,
+ "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)}
+ >
+ 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`] = `
[
,
,
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 ? (
+
- 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":
+
+ ,
+ "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/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 (
-
+
- WalletConnect
+
WalletConnect
{numberActiveSessions ? (
@@ -356,17 +355,15 @@ const WalletConnectDropdown = () => {
) : null}
-
-
-
{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`] = `
renders correctly and matches snapshot 1`] =
}
/>
WalletConnect
-
-
-
+ 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 (
+
+
+
+
+ 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));
+ }
+ }}
+ >
+
+
+
+ );
+};
+
+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":
+
+
+
+
+
+
+ My portfolio
+
+
+
+ Loading...
+
+
+
+
+
+
+
+ ,
+ "container":
+
+
+
+
+
+ My portfolio
+
+
+
+ Loading...
+
+
+
+
+
+
+
,
+ "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 (
+
+
+
dispatch(setIsReceiveModalOpen(true))}
+ >
+
+ Receive
+
+
+
+
+
+ );
+};
+
+export default WalletPortfolioButtons;
diff --git a/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx b/src/apps/pillarx-app/components/WalletPortfolioGraph/BalancePnlGraph.tsx
new file mode 100644
index 00000000..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'}
+ />
+
+
+ dispatch(setSelectedBalanceOrPnl('balance'))}
+ >
+
+ Balance
+
+
+ dispatch(setSelectedBalanceOrPnl('pnl'))}
+ >
+
+ 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 (
+
+ {text}
+
+ );
+};
+
+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
+
+ {text} {isActive && '✓'}
+
+ );
+ },
+}));
+
+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":
+
+
+
+
+
+ Balance
+
+
+
+ PnL
+
+
+
+
+
+
+ Balance
+
+
+
+
+ PnL
+
+
+
+
+
+ 24h
+
+
+
+ 1w
+
+
+
+ 1mo
+
+
+
+ 1y
+
+
+
+
+
+ Graph Component
+
+
+
+ ,
+ "container":
+
+
+
+
+ Balance
+
+
+
+ PnL
+
+
+
+
+
+
+ Balance
+
+
+
+
+ PnL
+
+
+
+
+
+ 24h
+
+
+
+ 1w
+
+
+
+ 1mo
+
+
+
+ 1y
+
+
+
+
+
+ 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));
+};