diff --git a/packages/frontend/src/MobileApp.tsx b/packages/frontend/src/MobileApp.tsx index 2496c0d8..ca7dbb39 100644 --- a/packages/frontend/src/MobileApp.tsx +++ b/packages/frontend/src/MobileApp.tsx @@ -35,6 +35,9 @@ const ConnectWalletPage = lazyRetry( ); const AddWalletPage = lazyRetry(() => import("./mobile-pages/add-wallet")); const AddHoldingPage = lazyRetry(() => import("./mobile-pages/add-holding")); +const PortfolioHoldingsPage = lazyRetry( + () => import("./mobile-pages/portfolio-holdings") +); const CustomNavTab: React.FC<{ label: string; @@ -100,6 +103,9 @@ const TabNavigator: React.FC = () => { exact component={AddHoldingPage} /> + + + diff --git a/packages/frontend/src/assets/icons/shown.svg b/packages/frontend/src/assets/icons/shown.svg index a78c62df..e8c9da84 100644 --- a/packages/frontend/src/assets/icons/shown.svg +++ b/packages/frontend/src/assets/icons/shown.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/frontend/src/components/portfolio/PortfolioStats.tsx b/packages/frontend/src/components/portfolio/PortfolioStats.tsx index aaf7c372..96a17c97 100644 --- a/packages/frontend/src/components/portfolio/PortfolioStats.tsx +++ b/packages/frontend/src/components/portfolio/PortfolioStats.tsx @@ -200,7 +200,7 @@ const PortfolioStats: FC = ({ Total Balance{" "}

{portfolioData === undefined ? ( diff --git a/packages/frontend/src/mobile-components/portfolio/PortfolioChart.tsx b/packages/frontend/src/mobile-components/portfolio/PortfolioChart.tsx new file mode 100644 index 00000000..a42c2679 --- /dev/null +++ b/packages/frontend/src/mobile-components/portfolio/PortfolioChart.tsx @@ -0,0 +1,130 @@ +import { FC, useMemo } from "react"; +import { ApexDonutChart, themeColors } from "@alphaday/ui-kit"; +import { makeRepeated } from "src/api/utils/itemUtils"; +import { getAssetPrefix } from "src/api/utils/portfolioUtils"; +import { ITEM_COLORS } from "src/components/item-colors"; +import { TPortfolioDataForAddress } from "src/components/portfolio/types"; +import CONFIG from "src/config"; +import { portfolioData } from "./mockData"; + +const { DONUT_TOKENS_COUNT } = CONFIG.WIDGETS.PORTFOLIO; + +const PortfolioChart: FC = () => { + const assets = useMemo( + () => + portfolioData !== undefined + ? [...portfolioData.assets].sort( + (a, b) => b.token.balanceUSD - a.token.balanceUSD + ) + : [], + [] + ); + + const labels: string[] = []; + const series = assets.map((item) => { + labels.push( + `${item.token.symbol}${getAssetPrefix(item).toUpperCase()}` + ); + return Number(item.token.balanceUSD); + }); + const othersBalance = + series.length > DONUT_TOKENS_COUNT + ? series.splice(DONUT_TOKENS_COUNT).reduce((n, p) => n + p) + : 0; + const donutData = { + options: { + chart: { + id: "portfolio-donut", + sparkline: { + enabled: false, + }, + background: "transparent", + redrawOnWindowResize: true, + height: "500px", + }, + labels: [ + ...labels.slice(0, DONUT_TOKENS_COUNT), + ...(othersBalance ? ["Others"] : []), + ], + dataLabels: { + enabled: false, + }, + tooltip: { + y: { + formatter(value: number) { + return `$${new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + }).format(value)}`; + }, + }, + }, + plotOptions: { + pie: { + donut: { + customScale: 1.5, + size: 170, + background: "transparent", + }, + }, + }, + stroke: { + colors: undefined, + }, + legend: { + show: true, + fontSize: "11px", + position: "left", + offsetX: 0, + offsetY: 0, + height: 500, + labels: { + colors: [themeColors.primaryVariant100], + useSeriesColors: false, + }, + markers: { + radius: 3, + }, + onItemHover() {}, + formatter( + label: string, + opts: { + w: { + globals: { + series: number[]; + }; + }; + seriesIndex: number; + } + ) { + const percent = + (100 * opts.w.globals.series[opts.seriesIndex]) / + opts.w.globals.series.reduce((a, b) => a + b); + return ` + +
${label}
+
 ${percent.toFixed(0)}%
+
`; + }, + }, + colors: makeRepeated(ITEM_COLORS, assets.length), + }, + series: [ + ...series.slice(0, DONUT_TOKENS_COUNT), + ...(othersBalance ? [othersBalance] : []), + ], + }; + + return ( +
+ {donutData.series && ( + + )} +
+ ); +}; +export default PortfolioChart; diff --git a/packages/frontend/src/mobile-components/portfolio/WalletConnectionOptions.tsx b/packages/frontend/src/mobile-components/portfolio/WalletConnectionOptions.tsx new file mode 100644 index 00000000..5601f196 --- /dev/null +++ b/packages/frontend/src/mobile-components/portfolio/WalletConnectionOptions.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import { OutlineButton, twMerge } from "@alphaday/ui-kit"; +import { ReactComponent as CopySVG } from "src/assets/icons/copy.svg"; +import { ReactComponent as HandSVG } from "src/assets/icons/hand.svg"; +import { ReactComponent as WalletSVG } from "src/assets/icons/wallet.svg"; + +const WalletConnectionOptions: FC<{ + isAuthenticated: boolean; + onClick: (path: string) => void; + className?: string; +}> = ({ isAuthenticated, onClick, className }) => { + return ( +
+ } + onClick={() => onClick("/portfolio/add-wallet")} + isAuthenticated={isAuthenticated} + /> + } + onClick={() => onClick("/portfolio/connect-wallet")} + isAuthenticated={isAuthenticated} + /> + } + onClick={() => onClick("/portfolio/add-holding")} + isAuthenticated={isAuthenticated} + /> +
+ ); +}; + +export default WalletConnectionOptions; diff --git a/packages/frontend/src/mobile-components/portfolio/mockData.ts b/packages/frontend/src/mobile-components/portfolio/mockData.ts new file mode 100644 index 00000000..144b7691 --- /dev/null +++ b/packages/frontend/src/mobile-components/portfolio/mockData.ts @@ -0,0 +1,177 @@ +import { TPortfolioDataForAddress } from "src/components/portfolio/types"; + +export const portfolioData: TPortfolioDataForAddress = { + assets: [ + { + key: "2216096905", + address: "0x2874c4e9cb5f183ef3d8adfa6d6bbd584dc67842", + network: "avalanche", + updatedAt: "2024-03-03T09:08:45.405Z", + token: { + id: "47", + address: "0x0000000000000000000000000000000000000000", + name: "AVAX", + symbol: "AVAX", + decimals: 18, + coingeckoId: "avalanche-2", + hide: false, + canExchange: true, + updatedAt: "2024-03-04T07:10:32.643Z", + createdAt: "2022-05-18T12:54:47.542Z", + price: 42.63, + networkId: 3, + status: "approved", + totalSupply: "435942308.442695", + dailyVolume: 621105842.2333122, + verified: true, + holdersEnabled: true, + marketCap: 16035840022.326565, + priceUpdatedAt: "2024-03-04T07:10:32.643Z", + externallyVerified: false, + label: "avax", + balance: 0.0021, + balanceUSD: 0.089523, + balanceRaw: "2100000000000000", + tokenImage: + "https://storage.googleapis.com/zapper-fi-assets/tokens/avalanche/0x0000000000000000000000000000000000000000.png", + }, + }, + { + key: "921602654", + address: "0x2874c4e9cb5f183ef3d8adfa6d6bbd584dc67842", + network: "optimism", + updatedAt: "2024-03-03T09:08:45.244Z", + token: { + id: "78", + address: "0x0000000000000000000000000000000000000000", + name: "Ether", + symbol: "ETH", + decimals: 18, + coingeckoId: "ethereum", + hide: false, + canExchange: true, + updatedAt: "2024-03-04T07:10:32.643Z", + createdAt: "2022-05-18T12:54:47.542Z", + price: 3463.77, + networkId: 11, + status: "approved", + totalSupply: "36811.197214188697856699", + dailyVolume: 17641941138.93259, + verified: true, + holdersEnabled: true, + marketCap: 416110233226.25, + priceUpdatedAt: "2024-03-04T07:10:32.643Z", + externallyVerified: false, + label: "eth", + balance: 0.008459516477201893, + balanceUSD: 29.3018193882376, + balanceRaw: "8459516477201892", + tokenImage: + "https://storage.googleapis.com/zapper-fi-assets/tokens/optimism/0x0000000000000000000000000000000000000000.png", + }, + }, + { + key: "3612627573", + address: "0x2874c4e9cb5f183ef3d8adfa6d6bbd584dc67842", + network: "ethereum", + updatedAt: "2024-03-03T09:08:45.213Z", + token: { + id: "3067", + address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + name: "Wrapped Ether", + symbol: "WETH", + decimals: 18, + coingeckoId: "weth", + hide: false, + canExchange: true, + updatedAt: "2024-03-04T07:10:32.643Z", + createdAt: "2022-05-18T12:54:47.695Z", + price: 3463.77, + networkId: 1, + status: "approved", + totalSupply: "3081838.685889420235408401", + dailyVolume: 553589763.6055393, + verified: false, + holdersEnabled: true, + marketCap: 0, + priceUpdatedAt: "2024-03-04T07:10:32.643Z", + externallyVerified: true, + label: "WETH", + balance: 30.56325, + balanceUSD: 105864.0684525, + balanceRaw: "30563250000000000000", + tokenImage: + "https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png", + }, + }, + { + key: "80289008", + address: "0x2874c4e9cb5f183ef3d8adfa6d6bbd584dc67842", + network: "ethereum", + updatedAt: "2024-03-03T09:08:45.213Z", + token: { + id: "1496", + address: "0x0000000000000000000000000000000000000000", + name: "Ether", + symbol: "ETH", + decimals: 18, + coingeckoId: "ethereum", + hide: false, + canExchange: true, + updatedAt: "2024-03-04T07:10:32.643Z", + createdAt: "2022-05-18T12:54:47.695Z", + price: 3463.77, + networkId: 1, + status: "approved", + totalSupply: "122373866.2178", + dailyVolume: 17641941138.93259, + verified: true, + holdersEnabled: true, + marketCap: 416110233226.25, + priceUpdatedAt: "2024-03-04T07:10:32.643Z", + externallyVerified: false, + label: "eth", + balance: 37.68335669707697, + balanceUSD: 130526.48042663428, + balanceRaw: "37683356697076965333", + tokenImage: + "https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0x0000000000000000000000000000000000000000.png", + }, + }, + { + key: "51396930", + address: "0x2874c4e9cb5f183ef3d8adfa6d6bbd584dc67842", + network: "ethereum", + updatedAt: "2024-03-02T23:39:41.598Z", + token: { + id: "2626", + address: "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", + name: "SHIBA INU", + symbol: "SHIB", + decimals: 18, + coingeckoId: "shiba-inu", + hide: false, + canExchange: true, + updatedAt: "2024-03-04T07:10:32.643Z", + createdAt: "2022-05-18T12:54:47.695Z", + price: 0.00002693, + networkId: 1, + status: "approved", + totalSupply: "999982367742850.863791106162366397", + dailyVolume: 4359038452.289908, + verified: false, + holdersEnabled: true, + marketCap: 15957709021.025589, + priceUpdatedAt: "2024-03-04T07:10:32.643Z", + externallyVerified: true, + label: "Shiba Inu", + balance: 434877.1472059143, + balanceUSD: 11.711241574255274, + balanceRaw: "434877147205914300000000", + tokenImage: + "https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce.png", + }, + }, + ], + totalValue: 236431.6514630968, +}; diff --git a/packages/frontend/src/mobile-pages/portfolio-holdings.tsx b/packages/frontend/src/mobile-pages/portfolio-holdings.tsx new file mode 100644 index 00000000..71aa5907 --- /dev/null +++ b/packages/frontend/src/mobile-pages/portfolio-holdings.tsx @@ -0,0 +1,252 @@ +import { FC, useState } from "react"; +import { Dialog, DropdownSelect } from "@alphaday/ui-kit"; +import { useHistory } from "react-router"; +import { useKeyPress } from "src/api/hooks"; +import { toggleShowBalance as toggleShowBalanceInStore } from "src/api/store"; +import { useAppDispatch } from "src/api/store/hooks"; + +import { TPortfolio } from "src/api/types"; +import { ENumberStyle, formatNumber } from "src/api/utils/format"; +import { getAssetPrefix } from "src/api/utils/portfolioUtils"; +import { ReactComponent as ArrowUpSVG } from "src/assets/icons/arrow-up.svg"; +import { ReactComponent as PlusSVG } from "src/assets/icons/plus.svg"; +import { ReactComponent as ShowSVG } from "src/assets/icons/shown.svg"; + +import PagedMobileLayout from "src/layout/PagedMobileLayout"; +import { portfolioData } from "src/mobile-components/portfolio/mockData"; +import PortfolioChart from "src/mobile-components/portfolio/PortfolioChart"; +import WalletConnectionOptions from "src/mobile-components/portfolio/WalletConnectionOptions"; +import CONFIG from "src/config"; + +const { SMALL_PRICE_CUTOFF_LG } = CONFIG.WIDGETS.PORTFOLIO; + +const UserWalletsInfo: FC<{ toggleBalance: () => void }> = ({ + toggleBalance, +}) => { + return ( +
+
+ username +
+

+ Total Balance{" "} + +

+

+ $12,555 +

+

+ +58.54 (1.4%) / + 24h +

+
+
+
+ ); +}; + +const WalletsList: FC<{ + onAddWallet: () => void; + wallets: { + label: string; + value: string; + }[]; +}> = ({ onAddWallet, wallets }) => { + return ( +
+ + +
+ ); +}; + +const AssetsList: FC<{ assets: TPortfolio[] }> = ({ assets }) => { + if (assets.length > 0) { + return ( +
+
+ {assets.map((asset) => { + const assetKey = `single- + ${asset.address}-${asset.network}-${asset.updatedAt}-${asset.token.id}`; + + return ( +
+
+ + {asset.token.tokenImage && ( + + )} + + {asset.token.symbol} + {" "} + + {asset.token.name} + + + {getAssetPrefix(asset)} + + +
+
+
+
+

+ Balance +

+ + { + formatNumber({ + value: asset.token + .balance, + }).value + } + +
+
+
+
+

+ Price +

+ + { + formatNumber({ + value: + asset.token.price || + 0, + style: ENumberStyle.Currency, + currency: "USD", + useEllipsis: true, + ellipsisCutoff: + SMALL_PRICE_CUTOFF_LG, + }).value + } + +
+
+
+
+

+ Value +

+ + { + formatNumber({ + value: + asset.token + .balanceUSD || + 0, + style: ENumberStyle.Currency, + currency: "USD", + }).value + } + +
+
+
+
+ ); + })} +
+
+ ); + } + return null; +}; + +const PortfolioHoldings: React.FC = () => { + const dispatch = useAppDispatch(); + const [showDialog, setShowDialog] = useState(false); + const history = useHistory(); + + const toggleBalance = () => { + dispatch(toggleShowBalanceInStore()); + }; + const wallets = [ + { label: "All Assets", value: "all" }, + { + label: "xavier-charles", + value: "0xe70d4BdacC0444CAa973b0A05CB6f2974C34aF0c", + }, + { + label: "brine", + value: "0x93450d4BdacC0444CAa973b0A05CB6f2974C34DDc", + }, + ]; + return ( + + {/* // TODO (xavier-charles) update classname */} +
+ + { + setShowDialog(true); + }} + /> +
+
+

+ Balance +

+

+ + {" "} + (1.4%) + {" "} + / 24h +

+
+

$12,555

+
+ + + setShowDialog(false)} + > +
+
+ Choose A Method +
+ { + history.push(path); + }} + /> +
+
+
+
+ ); +}; + +export default PortfolioHoldings; diff --git a/packages/frontend/src/mobile-pages/portfolio.tsx b/packages/frontend/src/mobile-pages/portfolio.tsx index 4fb4ea34..5ef3866a 100644 --- a/packages/frontend/src/mobile-pages/portfolio.tsx +++ b/packages/frontend/src/mobile-pages/portfolio.tsx @@ -1,12 +1,10 @@ import { FC } from "react"; -import { OutlineButton, Pager } from "@alphaday/ui-kit"; +import { Pager } from "@alphaday/ui-kit"; import { useHistory } from "react-router"; import { useAuth } from "src/api/hooks"; import { ReactComponent as CloseSVG } from "src/assets/icons/close.svg"; -import { ReactComponent as CopySVG } from "src/assets/icons/copy.svg"; -import { ReactComponent as HandSVG } from "src/assets/icons/hand.svg"; import { ReactComponent as InfoSVG } from "src/assets/icons/Info2.svg"; -import { ReactComponent as WalletSVG } from "src/assets/icons/wallet.svg"; +import WalletConnectionOptions from "src/mobile-components/portfolio/WalletConnectionOptions"; const TopSection: FC<{ isAuthenticated: boolean }> = ({ isAuthenticated }) => { if (isAuthenticated) { @@ -35,6 +33,8 @@ const TopSection: FC<{ isAuthenticated: boolean }> = ({ isAuthenticated }) => { const PortfolioPage = () => { const history = useHistory(); const { isAuthenticated } = useAuth(); + + // TODO if user has holdings route to holdings return ( <> { } /> -
- } - onClick={() => history.push("/portfolio/add-wallet")} - isAuthenticated={isAuthenticated} - /> - } - onClick={() => history.push("/portfolio/connect-wallet")} - isAuthenticated={isAuthenticated} - /> - } - onClick={() => history.push("/portfolio/add-holding")} - isAuthenticated={isAuthenticated} - /> -
+ { + history.push(path); + }} + className="mx-4" + /> ); }; diff --git a/packages/ui-kit/index.ts b/packages/ui-kit/index.ts index b0abeabb..72aac5f2 100644 --- a/packages/ui-kit/index.ts +++ b/packages/ui-kit/index.ts @@ -92,8 +92,8 @@ import { import { breakpoints } from "./src/globalStyles/breakpoints"; import { themeColors } from "./src/globalStyles/themes"; import { OutlineButton } from "./src/mobile-components/button/buttons"; -import { FormInput } from "./src/mobile-components/form-elements/FormElements"; import { Pager } from "./src/mobile-components/pager/Pager"; +import { DropdownSelect } from "./src/mobile-components/select/DropdownSelect"; export type { TViewTabMenuOption, DatesSetArg, EventClickArg, TDatePos }; export * from "./src/mobile-components/form-elements/FormElements"; @@ -179,5 +179,5 @@ export { Transition, MiniDialog, OutlineButton, - FormInput, + DropdownSelect, }; diff --git a/packages/ui-kit/src/mobile-components/pager/Pager.tsx b/packages/ui-kit/src/mobile-components/pager/Pager.tsx index f0b25286..6c0c6a85 100644 --- a/packages/ui-kit/src/mobile-components/pager/Pager.tsx +++ b/packages/ui-kit/src/mobile-components/pager/Pager.tsx @@ -23,6 +23,7 @@ export const Pager: React.FC = ({ handleBack && "visible" )} onClick={handleBack} + title="Back" > @@ -36,6 +37,7 @@ export const Pager: React.FC = ({ handleClose && "visible" )} onClick={handleClose} + title="Close" > diff --git a/packages/ui-kit/src/mobile-components/select/DropdownSelect.tsx b/packages/ui-kit/src/mobile-components/select/DropdownSelect.tsx new file mode 100644 index 00000000..a101947b --- /dev/null +++ b/packages/ui-kit/src/mobile-components/select/DropdownSelect.tsx @@ -0,0 +1,104 @@ +import { FC, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { ReactComponent as CheckmarkSVG } from "../../assets/svg/checkmark.svg"; +import { ReactComponent as ChevronUpDownSVG } from "../../assets/svg/chevron-up-down.svg"; + +type TItem = { + id: number; + name: string; +}; + +interface IDropdownSelect { + label?: string; + items: { + label: string; + value: string; + }[]; +} + +export const DropdownSelect: FC = ({ label, items }) => { + const [selectedItem, setSelectedItem] = useState(items[0]); + return ( + + {label && ( + + {label} + + )} +
+ + setSelectedItem( + items.find( + (item) => item.value === event.target.value + ) || items[0] + ) + } + value={selectedItem?.label} + displayValue={(Item: TItem) => Item?.name} + /> + + + + {items.length > 0 && ( + + {items.map((item) => ( + + twMerge( + "relative cursor-default select-none py-2 pl-3 pr-9", + active + ? "bg-accentVariant100 cursor-pointer text-white" + : "text-primary" + ) + } + > + {({ active, selected }) => ( + <> + + {item.label} + + + {selected && ( + + + )} + + )} + + ))} + + )} +
+
+ ); +};