From 3d4492a07dc1ebe1d9410ac23a23c467816152c5 Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Sun, 27 Oct 2024 16:37:32 +0100 Subject: [PATCH 1/6] feat: add API service, add fetch logic & add components --- app/[addressOrDomain]/page.tsx | 218 +++++++++++++++++- components/{discover => UI}/appIcon.tsx | 0 components/dashboard/PortfolioSummary.tsx | 131 +++++++++++ components/discover/claimModal.tsx | 2 +- components/discover/defiTable.tsx | 2 +- .../skeletons/portfolioSummarySkeleton.tsx | 26 +++ services/argentPortfolioService.ts | 97 ++++++++ styles/dashboard.module.css | 44 +++- styles/globals.css | 1 + types/backTypes.d.ts | 98 ++++++++ types/frontTypes.d.ts | 7 + utils/feltService.ts | 9 + 12 files changed, 623 insertions(+), 12 deletions(-) rename components/{discover => UI}/appIcon.tsx (100%) create mode 100644 components/dashboard/PortfolioSummary.tsx create mode 100644 components/skeletons/portfolioSummarySkeleton.tsx create mode 100644 services/argentPortfolioService.ts diff --git a/app/[addressOrDomain]/page.tsx b/app/[addressOrDomain]/page.tsx index 534a8205..254d977c 100644 --- a/app/[addressOrDomain]/page.tsx +++ b/app/[addressOrDomain]/page.tsx @@ -12,13 +12,13 @@ import { useAccount } from "@starknet-react/core"; import Blur from "@components/shapes/blur"; import { utils } from "starknetid.js"; import { StarknetIdJsContext } from "@context/StarknetIdJsProvider"; -import { hexToDecimal } from "@utils/feltService"; +import { hexToDecimal, tokenToDecimal } from "@utils/feltService"; import { isHexString, minifyAddress } from "@utils/stringService"; import ProfileCardSkeleton from "@components/skeletons/profileCardSkeleton"; import { getDataFromId } from "@services/starknetIdService"; import { usePathname, useRouter } from "next/navigation"; import ErrorScreen from "@components/UI/screens/errorScreen"; -import { CompletedQuests } from "../../types/backTypes"; +import { ArgentDappMap, ArgentTokenMap, ArgentUserDapp, ArgentUserToken, CompletedQuests } from "../../types/backTypes"; import QuestSkeleton from "@components/skeletons/questsSkeleton"; import QuestCardCustomised from "@components/dashboard/CustomisedQuestCard"; import QuestStyles from "@styles/Home.module.css"; @@ -31,6 +31,10 @@ import { TEXT_TYPE } from "@constants/typography"; import { a11yProps } from "@components/UI/tabs/a11y"; import { CustomTabPanel } from "@components/UI/tabs/customTab"; import SuggestedQuests from "@components/dashboard/SuggestedQuests"; +import PortfolioSummary from "@components/dashboard/PortfolioSummary"; +import { useNotification } from "@context/NotificationProvider"; +import { calculateTokenPrice, fetchDapps, fetchTokens, fetchUserDapps, fetchUserTokens } from "@services/argentPortfolioService"; +import PortfolioSummarySkeleton from "@components/skeletons/portfolioSummarySkeleton"; type AddressOrDomainProps = { params: { @@ -38,6 +42,19 @@ type AddressOrDomainProps = { }; }; +type ChartItemMap = { + [dappId: string]: ChartItem +}; + +type DebtStatus = { + hasDebt: boolean; + tokens: { + dappId: string, + tokenAddress: string, + tokenBalance: number + }[]; +}; + export default function Page({ params }: AddressOrDomainProps) { const router = useRouter(); const addressOrDomain = params.addressOrDomain; @@ -62,6 +79,17 @@ export default function Page({ params }: AddressOrDomainProps) { const [questsLoading, setQuestsLoading] = useState(true); const [tabIndex, setTabIndex] = React.useState(0); const [claimableQuests, setClaimableQuests] = useState([]); + const [portfolioAssets, setPortfolioAssets] = useState([]); + const [portfolioProtocols, setPortfolioProtocols] = useState([]); + const portfolioProtocolColors = [ + "#278015", + "#23F51F", + "#DEFE5C", + "#9EFABB", + "#F4FAFF" + ]; + const { showNotification } = useNotification(); + const [loadingProtocols, setLoadingProtocols] = useState(true); const handleChangeTab = useCallback( (event: React.SyntheticEvent, newValue: number) => { @@ -168,10 +196,169 @@ export default function Page({ params }: AddressOrDomainProps) { setQuestsLoading(false); }, []); + const fetchPortfolioAssets = useCallback(async (addr: string) => { + + // TODO: Implement fetch from Argent API + const assets = [ + { color: "#1E2097", itemLabel: "USDC", itemValue: "46.68", itemValueSymbol: "%" }, + { color: "#637DEB", itemLabel: "USDT", itemValue: "27.94", itemValueSymbol: "%" }, + { color: "#2775CA", itemLabel: "STRK", itemValue: "22.78", itemValueSymbol: "%" }, + { color: "#5CE3FE", itemLabel: "ETH", itemValue: "0.36", itemValueSymbol: "%" }, + { color: "#F4FAFF", itemLabel: "Others", itemValue: "2.36", itemValueSymbol: "%" }, + ]; + setPortfolioAssets(assets); + + }, []); + + const userHasDebt = (userDapps: ArgentUserDapp[]) => { + let debt: DebtStatus = { hasDebt: false, tokens: [] }; + + for (const dapp of userDapps) { + for (const position of dapp.products[0].positions) { + for (const tokenAddress of Object.keys(position.totalBalances)) { + const tokenBalance = Number(position.totalBalances[tokenAddress]); + if (tokenBalance < 0) { + debt.hasDebt = true; + debt.tokens.push({dappId: dapp.dappId, tokenAddress, tokenBalance}); + } + } + } + } + return debt; + }; + + const handleDebt = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap) => { + const debtStatus = userHasDebt(userDapps); + if (!debtStatus.hasDebt) { return; } + + for await (const debt of debtStatus.tokens) { + let value = Number(protocolsMap[debt.dappId].itemValue); + value += await calculateTokenPrice( + debt.tokenAddress, + tokenToDecimal(debt.tokenBalance.toString(), + tokens[debt.tokenAddress].decimals), + "USD" + ); + + protocolsMap[debt.dappId].itemValue = value.toFixed(2); + } + }; + + const getProtocolsFromTokens = async (protocolsMap: ChartItemMap, userTokens: ArgentUserToken[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => { + for await (const token of userTokens) { + const tokenInfo = tokens[token.tokenAddress]; + if (tokenInfo.dappId && token.tokenBalance != "0") { + let itemValue = 0; + const currentTokenBalance = await calculateTokenPrice(token.tokenAddress, tokenToDecimal(token.tokenBalance, tokenInfo.decimals), "USD"); + + if (protocolsMap[tokenInfo.dappId]?.itemValue) { + itemValue = Number(protocolsMap[tokenInfo.dappId].itemValue) + currentTokenBalance; + } else { + itemValue = currentTokenBalance; + } + + protocolsMap[tokenInfo.dappId] = { + color: "", + itemLabel: dapps[tokenInfo.dappId].name, + itemValueSymbol: "$", + itemValue: itemValue.toFixed(2) + } + } + } + } + + const getProtocolsFromDapps = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => { + for await (const userDapp of userDapps) { + if (protocolsMap[userDapp.dappId]) { continue; } // Ignore entry if already present in the map + + let protocolBalance = 0; + for await (const position of userDapp.products[0].positions) { + for await (const tokenAddress of Object.keys(position.totalBalances)) { + protocolBalance += await calculateTokenPrice( + tokenAddress, + tokenToDecimal(position.totalBalances[tokenAddress], tokens[tokenAddress].decimals), + "USD" + ); + } + } + + protocolsMap[userDapp.dappId] = { + color: "", + itemLabel: dapps[userDapp.dappId].name, + itemValueSymbol: "$", + itemValue: protocolBalance.toFixed(2) + } + } + } + + const sortProtocols = (protocolsMap: ChartItemMap) => { + return Object.values(protocolsMap).toSorted((a, b) => parseFloat(b.itemValue) - parseFloat(a.itemValue)); + } + + const handleExtraProtocols = (sortedProtocols: ChartItem[]) => { + let otherProtocols = sortedProtocols.length > 5 ? sortedProtocols.splice(4) : []; + if (otherProtocols.length === 0) { return;} + sortedProtocols.push({ + itemLabel: "Others", + itemValue: otherProtocols.reduce((valueSum, protocol) => valueSum + Number(protocol.itemValue), 0).toFixed(2), + itemValueSymbol: "$", + color: "" + }); + } + + const assignProtocolColors = (sortedProtocols: ChartItem[]) => { + sortedProtocols.forEach((protocol, index) => { + protocol.color = portfolioProtocolColors[index]; + }); + } + + const fetchPortfolioProtocols = useCallback(async (addr: string) => { + // addr = '0x05f1f8de723d8117daa26ec24320d0eacabc53a3d642acb0880846486e73283a'; + let dapps: ArgentDappMap = {}; + let tokens: ArgentTokenMap = {}; + let userTokens: ArgentUserToken[] = []; + let userDapps: ArgentUserDapp[] = []; + + setLoadingProtocols(true); + try { + [dapps, tokens, userTokens, userDapps] = await Promise.all([ + fetchDapps(), + fetchTokens(), + fetchUserTokens(addr), + fetchUserDapps(addr) + ]); + } catch (error) { + showNotification("Error while fetching address portfolio", "error"); + console.log("Error while fetching address portfolio", error); + } + + if (!dapps || !tokens || (!userTokens && !userDapps)) return; + let protocolsMap: ChartItemMap = {}; + + try { + await getProtocolsFromTokens(protocolsMap, userTokens, tokens, dapps); + await handleDebt(protocolsMap, userDapps, tokens); // Tokens show debt as balance 0, so need to handle it manually + await getProtocolsFromDapps(protocolsMap, userDapps, tokens, dapps); + + let sortedProtocols = sortProtocols(protocolsMap); + handleExtraProtocols(sortedProtocols); + assignProtocolColors(sortedProtocols); + + setPortfolioProtocols(sortedProtocols); + } catch (error) { + showNotification("Error while calculating address portfolio stats", "error"); + console.log("Error while calculating address portfolio stats", error); + } + + setLoadingProtocols(false); + }, []); + useEffect(() => { if (!identity) return; fetchQuestData(identity.owner); fetchPageData(identity.owner); + fetchPortfolioAssets(identity.owner); + fetchPortfolioProtocols(identity.owner); }, [identity]); useEffect(() => setNotFound(false), [dynamicRoute]); @@ -325,6 +512,33 @@ export default function Page({ params }: AddressOrDomainProps) { )} + {/* Portfolio charts */} +
+ {loadingProtocols ? ( + + ) : ( + sum + Number(item.itemValue), 0)} + isProtocol={false} + isLoading={false} + /> + )} + {loadingProtocols ? ( + + ) : ( + sum + Number(item.itemValue), 0)} + isProtocol={true} + isLoading={loadingProtocols} + /> + )} + +
+ {/* Completed Quests */}
diff --git a/components/discover/appIcon.tsx b/components/UI/appIcon.tsx similarity index 100% rename from components/discover/appIcon.tsx rename to components/UI/appIcon.tsx diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx new file mode 100644 index 00000000..495e576f --- /dev/null +++ b/components/dashboard/PortfolioSummary.tsx @@ -0,0 +1,131 @@ +import React, { FunctionComponent, useCallback, useContext, useEffect, useState } from "react"; +import AppIcon from "@components/UI/appIcon"; +import { TEXT_TYPE } from "@constants/typography"; +import Typography from "@components/UI/typography/typography"; +import { Doughnut } from "react-chartjs-2"; +import styles from "@styles/dashboard.module.css"; +import { Chart, ArcElement, DoughnutController, Tooltip } from 'chart.js'; +import cursor from '../../public/icons/cursor.png'; +import cursorPointer from '../../public/icons/pointer-cursor.png'; + +Chart.register(ArcElement, DoughnutController, Tooltip); + +type PortfolioSummaryProps = { + title: string, + data: ChartItem[], + totalBalance: number, + isProtocol: boolean, + isLoading: boolean +} + +const ChartItem: FunctionComponent = ({ + color, + itemLabel, + itemValue, + itemValueSymbol +}) => { + const value = itemValueSymbol === '%' ? itemValue + itemValueSymbol : itemValueSymbol + itemValue; + return ( +
+
+ + + + {itemLabel} +
+ {value} +
+ ); +}; + +const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol, isLoading }) => { + const normalizeMinValue = (data: ChartItem[], minPercentage: number) => { + return data.map(entry => Number(entry.itemValue) < totalBalance * minPercentage ? (totalBalance * minPercentage).toFixed(2) : entry.itemValue) + } + + return data.length > 0 ? ( +
+
+ + {title} + + {isProtocol ? + + : + <> + } +
+
+
+ { + data.map((item, id) => ( + + )) + } +
+
+ entry.itemLabel), + datasets: [{ + label: '', + data: normalizeMinValue(data, .05), + backgroundColor: data.map(entry => entry.color), + borderColor: data.map(entry => entry.color), + borderWidth: 1, + }], + }} + options={{ + elements: { + arc: { + borderAlign: "inner", + borderRadius: 3, + spacing: 1, + hoverOffset: 1, + hoverBorderColor: "white", + hoverBorderWidth: 1 + } + }, + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + position: "nearest", + xAlign: "center", + yAlign: "top", + callbacks: { + label: function (tooltipItem) { + return `${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; + } + } + } + }, + onHover: (event, element) => { + let canvas = event.native?.target as HTMLCanvasElement; + canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`; + } + }} + /> +
+
+
+ ) : ( + <> + ); +} + +export default PortfolioSummary; diff --git a/components/discover/claimModal.tsx b/components/discover/claimModal.tsx index e8989d5a..869114c9 100644 --- a/components/discover/claimModal.tsx +++ b/components/discover/claimModal.tsx @@ -6,7 +6,7 @@ import Typography from "@components/UI/typography/typography"; import Button from "@components/UI/button"; import { CDNImg } from "@components/cdn/image"; import { TEXT_TYPE } from "@constants/typography"; -import AppIcon from "./appIcon"; +import AppIcon from "../UI/appIcon"; import TokenIcon from "./tokenIcon"; import { useNotification } from "@context/NotificationProvider"; import Loading from "@app/loading"; diff --git a/components/discover/defiTable.tsx b/components/discover/defiTable.tsx index 1cf801ec..6283f01f 100644 --- a/components/discover/defiTable.tsx +++ b/components/discover/defiTable.tsx @@ -28,7 +28,7 @@ import { STABLES, TOKEN_OPTIONS, } from "@constants/defi"; -import AppIcon from "./appIcon"; +import AppIcon from "../UI/appIcon"; import ActionText from "./actionText"; import DownIcon from "@components/UI/iconsComponents/icons/downIcon"; import UpIcon from "@components/UI/iconsComponents/icons/upIcon"; diff --git a/components/skeletons/portfolioSummarySkeleton.tsx b/components/skeletons/portfolioSummarySkeleton.tsx new file mode 100644 index 00000000..f080b410 --- /dev/null +++ b/components/skeletons/portfolioSummarySkeleton.tsx @@ -0,0 +1,26 @@ +import React, { FunctionComponent } from "react"; +import { Skeleton } from "@mui/material"; +import styles from "@styles/dashboard.module.css"; + +const PortfolioSummarySkeleton: FunctionComponent = () => { + return ( + <> +
+ + +
+ + ); +}; + +export default PortfolioSummarySkeleton; \ No newline at end of file diff --git a/services/argentPortfolioService.ts b/services/argentPortfolioService.ts new file mode 100644 index 00000000..cab1f540 --- /dev/null +++ b/services/argentPortfolioService.ts @@ -0,0 +1,97 @@ +import { + ArgentDapp, + ArgentDappMap, + ArgentUserDapp, + ArgentToken, + ArgentTokenValue, + ArgentTokenMap, + ArgentUserToken, +} from "types/backTypes"; + +const API_BASE = "cloud.argent-api.com"; +const API_VERSION = "v1"; + +export const fetchDapps = async () => { + try { + const response = await fetch( + `https://${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet` + ); + const data: ArgentDapp[] = await response.json(); + + return Object.fromEntries( + data.map((dapp) => [dapp.dappId, dapp]) + ) as ArgentDappMap; + } catch (err) { + console.log("Error while fetching dapps from Argent API", err); + throw new Error("Error while fetching dapps from Argent API"); + } +}; + +export const fetchTokens = async () => { + try { + const response = await fetch( + `https://${API_BASE}/${API_VERSION}/tokens/info?chain=starknet` + ); + const data: { tokens: [ArgentToken] } = await response.json(); + + return Object.fromEntries( + data.tokens.map((token) => [token.address, token]) + ) as ArgentTokenMap; + } catch (err) { + console.log("Error while fetching token from Argent API", err); + throw new Error("Error while fetching token from Argent API"); + } +}; + +export const fetchUserTokens = async (walletAddress: string) => { + const opts = { + headers: { + "argent-client": "portfolio", + "argent-network": "mainnet", + "argent-version": "1.4.3", + }, + }; + + try { + const response = await fetch( + `https://${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`, + opts + ); + const data: { balances: ArgentUserToken[]; status: string } = + await response.json(); + return data.balances; + } catch (err) { + console.log("Error while fetching wallet dapps from Argent API", err); + throw new Error("Error while fetching wallet dapps from Argent API"); + } +}; + +export const fetchUserDapps = async (walletAddress: string) => { + try { + const response = await fetch( + `https://${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet` + ); + const data: { dapps: ArgentUserDapp[] } = await response.json(); + return data.dapps; + } catch (err) { + console.log("Error while fetching wallet dapps from Argent API", err); + throw new Error("Error while fetching wallet dapps from Argent API"); + } +}; + +export const calculateTokenPrice = async ( + tokenAddress: string, + tokenAmount: Big.Big, + currency: "USD" | "EUR" | "GBP" = "USD" +) => { + try { + const response = await fetch( + `https://${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}` + ); + const data: ArgentTokenValue = await response.json(); + return tokenAmount.mul(data.ccyValue).toNumber(); + } catch (err) { + console.log("Error while fetching token price from Argent API", err); + throw new Error("Error while fetching token price from Argent API"); + } +}; \ No newline at end of file diff --git a/styles/dashboard.module.css b/styles/dashboard.module.css index 22fc8db7..c68d2f54 100644 --- a/styles/dashboard.module.css +++ b/styles/dashboard.module.css @@ -12,7 +12,7 @@ width: 100%; justify-content: center; margin-top: 12vh; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 24px; } @@ -20,7 +20,7 @@ display: flex; width: 100%; margin-top: 12vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .profileCardSkeleton { @@ -42,7 +42,7 @@ height: 30vh; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .dashboardLoading { @@ -56,7 +56,7 @@ display: flex; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .questsCompletedTitleLoading { @@ -87,7 +87,7 @@ .dashboard_profile_card { display: flex; width: 100%; - max-width: 950px; + /* max-width: var(--dashboard-max-width); */ padding: 1.5rem 2.5rem; background: linear-gradient(var(--background600), transparent); align-items: center; @@ -283,7 +283,7 @@ flex-direction: row; flex-wrap: wrap; gap: 2rem; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 3rem; justify-content: space-between; } @@ -293,7 +293,7 @@ flex-direction: column; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 24px; } @@ -318,6 +318,34 @@ text-align: center; } +.dashboard_portfolio_summary_container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin-top: 6vh; + max-width: var(--dashboard-max-width); + margin-bottom: 24px; +} + +.dashboard_portfolio_summary { + width: 47%; +} + +.dashboard_portfolio_summary_info { + border-radius: 8px; + padding: 24px; + width: 100%; + max-height: 500px; + display: flex; + flex-direction: row; + background-color: var(--menu-background); + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + border: solid 1px transparent; +} + @media (max-width: 768px) { .dashboard_profile_card { flex-direction: column; @@ -369,7 +397,7 @@ flex-direction: row; flex-wrap: wrap; gap: 2rem; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 3rem; margin-top: 1rem; margin-top: 3rem; diff --git a/styles/globals.css b/styles/globals.css index ab3e40da..d0821815 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -34,6 +34,7 @@ --white: #fff; --textGray: #a6a5a7; --transparent: transparent; + --dashboard-max-width: 90%; } html, diff --git a/types/backTypes.d.ts b/types/backTypes.d.ts index e6b431c7..cf5620ae 100644 --- a/types/backTypes.d.ts +++ b/types/backTypes.d.ts @@ -528,3 +528,101 @@ export type Call = { calldata: string[]; entrypoint: string; }; + +export type ArgentDappLink = { + name: string; + url: string; + position: number; +}; + +export type ArgentDappContract = { + address: string; + chain: string; +}; + +export type ArgentDapp = { + dappId: string; + name: string; + description: string; + logoUrl: string; + dappUrl?: string; + inAppBrowserCompatible: boolean; + argentVerified: boolean; + links: ArgentDappLink[]; + contracts: ArgentDappContract[]; + categories: string[]; + supportedApps?: string[]; + executeFromOutsideAllowed: boolean; +}; + +export type ArgentDappMap = { + [dappId: string]: ArgentDapp; +}; + +export type ArgentToken = { + id: number; + address: string; + name: string; + tradable: boolean; + symbol: string; + decimals: number; + sendable: boolean; + category: string; + refundable: boolean; + popular: boolean; + listed: boolean; + dappId?: string; +}; + +export type ArgentTokenMap = { + [address: string]: ArgentToken; +}; + +export type ArgentDappPositionTotalBalances = { + [address: string]: string; +}; + +export type ArgentDappPositionData = { + apy: string; + group?: number; + collateral: boolean; + debt: boolean; + lending: boolean; +}; + +export type ArgentDappPosition = { + id: string; + investmentId?: string; + tokenAddress: string; + tokenAmount: string; + totalBalances: ArgentDappPositionTotalBalances; + data: ArgentDappPositionData; +}; + +export type ArgentDappProduct = { + productId?: string; + name: string; + manageUrl: string; + groups?: { [key: string]: { healthRatio: string } }; + positions: ArgentDappPosition[]; + type: string; +}; + +export type ArgentUserDapp = { + dappId: string; + products: ArgentDappProduct[]; +}; + +export type ArgentTokenValue = { + pricingId: number; + date: number; + ethValue: string; + ccyValue: string; + ethDayChange: string; + ccyDayChange: string; +} + +export type ArgentUserToken = { + tokenAddress: string; + tokenBalance: string; +} \ No newline at end of file diff --git a/types/frontTypes.d.ts b/types/frontTypes.d.ts index 25492fa8..d0f9c6bf 100644 --- a/types/frontTypes.d.ts +++ b/types/frontTypes.d.ts @@ -347,3 +347,10 @@ type Call = { calldata: string[]; entrypoint: string; }; + +type ChartItem = { + color: string, + itemLabel: string, + itemValue: string, + itemValueSymbol: string +} \ No newline at end of file diff --git a/utils/feltService.ts b/utils/feltService.ts index dc0e243c..021a31b8 100644 --- a/utils/feltService.ts +++ b/utils/feltService.ts @@ -36,3 +36,12 @@ export function gweiToEth(gwei: string): string { return ethBigInt.toString(); } + +export function tokenToDecimal(tokenValue: string, decimals: number) { + const tokenValueBigInt = new Big(tokenValue); + const scaleFactor = new Big(10 ** decimals); + + const tokenDecimal = tokenValueBigInt.div(scaleFactor).round(5); + + return tokenDecimal; +} From 8d5b8756f35e4ab799263650ddd0e92520deb5df Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Sun, 27 Oct 2024 18:34:17 +0100 Subject: [PATCH 2/6] feat: make components responsive --- app/[addressOrDomain]/page.tsx | 18 ++-- components/dashboard/PortfolioSummary.tsx | 83 ++++++++++--------- .../skeletons/portfolioSummarySkeleton.tsx | 5 +- styles/dashboard.module.css | 12 +++ 4 files changed, 66 insertions(+), 52 deletions(-) diff --git a/app/[addressOrDomain]/page.tsx b/app/[addressOrDomain]/page.tsx index 254d977c..7f1bc3a4 100644 --- a/app/[addressOrDomain]/page.tsx +++ b/app/[addressOrDomain]/page.tsx @@ -58,6 +58,7 @@ type DebtStatus = { export default function Page({ params }: AddressOrDomainProps) { const router = useRouter(); const addressOrDomain = params.addressOrDomain; + const { showNotification } = useNotification(); const { address } = useAccount(); const { starknetIdNavigator } = useContext(StarknetIdJsContext); const [initProfile, setInitProfile] = useState(false); @@ -81,14 +82,6 @@ export default function Page({ params }: AddressOrDomainProps) { const [claimableQuests, setClaimableQuests] = useState([]); const [portfolioAssets, setPortfolioAssets] = useState([]); const [portfolioProtocols, setPortfolioProtocols] = useState([]); - const portfolioProtocolColors = [ - "#278015", - "#23F51F", - "#DEFE5C", - "#9EFABB", - "#F4FAFF" - ]; - const { showNotification } = useNotification(); const [loadingProtocols, setLoadingProtocols] = useState(true); const handleChangeTab = useCallback( @@ -307,13 +300,19 @@ export default function Page({ params }: AddressOrDomainProps) { } const assignProtocolColors = (sortedProtocols: ChartItem[]) => { + const portfolioProtocolColors = [ + "#278015", + "#23F51F", + "#DEFE5C", + "#9EFABB", + "#F4FAFF" + ]; sortedProtocols.forEach((protocol, index) => { protocol.color = portfolioProtocolColors[index]; }); } const fetchPortfolioProtocols = useCallback(async (addr: string) => { - // addr = '0x05f1f8de723d8117daa26ec24320d0eacabc53a3d642acb0880846486e73283a'; let dapps: ArgentDappMap = {}; let tokens: ArgentTokenMap = {}; let userTokens: ArgentUserToken[] = []; @@ -536,7 +535,6 @@ export default function Page({ params }: AddressOrDomainProps) { isLoading={loadingProtocols} /> )} -
{/* Completed Quests */} diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx index 495e576f..cc8a8d53 100644 --- a/components/dashboard/PortfolioSummary.tsx +++ b/components/dashboard/PortfolioSummary.tsx @@ -1,10 +1,11 @@ import React, { FunctionComponent, useCallback, useContext, useEffect, useState } from "react"; -import AppIcon from "@components/UI/appIcon"; import { TEXT_TYPE } from "@constants/typography"; import Typography from "@components/UI/typography/typography"; import { Doughnut } from "react-chartjs-2"; import styles from "@styles/dashboard.module.css"; -import { Chart, ArcElement, DoughnutController, Tooltip } from 'chart.js'; +import { Chart, ArcElement, DoughnutController, Tooltip, ChartEvent, ActiveElement, TooltipItem, ChartOptions } from 'chart.js'; +import { CDNImg } from "@components/cdn/image"; +import starknetIcon from "../../public/starknet/favicon.ico"; import cursor from '../../public/icons/cursor.png'; import cursorPointer from '../../public/icons/pointer-cursor.png'; @@ -43,19 +44,52 @@ const PortfolioSummary: FunctionComponent = ({ title, dat return data.map(entry => Number(entry.itemValue) < totalBalance * minPercentage ? (totalBalance * minPercentage).toFixed(2) : entry.itemValue) } + const chartOptions: ChartOptions<"doughnut"> = { + elements: { + arc: { + borderAlign: "inner", + borderRadius: 3, + spacing: 1, + hoverOffset: 1, + hoverBorderColor: "white", + hoverBorderWidth: 1 + } + }, + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + position: "nearest", + xAlign: "center", + yAlign: "top", + callbacks: { + label: function (tooltipItem: TooltipItem<"doughnut">) { + return `${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; + } + } + } + }, + onHover: (event: ChartEvent, element: ActiveElement[]) => { + let canvas = event.native?.target as HTMLCanvasElement; + canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`; + } + } + return data.length > 0 ? (
-
- - {title} - +
+
+ + {title} + +
{isProtocol ? @@ -89,36 +123,7 @@ const PortfolioSummary: FunctionComponent = ({ title, dat borderWidth: 1, }], }} - options={{ - elements: { - arc: { - borderAlign: "inner", - borderRadius: 3, - spacing: 1, - hoverOffset: 1, - hoverBorderColor: "white", - hoverBorderWidth: 1 - } - }, - responsive: true, - maintainAspectRatio: false, - plugins: { - tooltip: { - position: "nearest", - xAlign: "center", - yAlign: "top", - callbacks: { - label: function (tooltipItem) { - return `${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; - } - } - } - }, - onHover: (event, element) => { - let canvas = event.native?.target as HTMLCanvasElement; - canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`; - } - }} + options={chartOptions} />
diff --git a/components/skeletons/portfolioSummarySkeleton.tsx b/components/skeletons/portfolioSummarySkeleton.tsx index f080b410..516f7d7e 100644 --- a/components/skeletons/portfolioSummarySkeleton.tsx +++ b/components/skeletons/portfolioSummarySkeleton.tsx @@ -5,16 +5,15 @@ import styles from "@styles/dashboard.module.css"; const PortfolioSummarySkeleton: FunctionComponent = () => { return ( <> -
+
diff --git a/styles/dashboard.module.css b/styles/dashboard.module.css index c68d2f54..6e7e2210 100644 --- a/styles/dashboard.module.css +++ b/styles/dashboard.module.css @@ -322,6 +322,7 @@ display: flex; flex-direction: row; justify-content: space-between; + align-items: flex-end; width: 100%; margin-top: 6vh; max-width: var(--dashboard-max-width); @@ -330,6 +331,7 @@ .dashboard_portfolio_summary { width: 47%; + margin-bottom: 6vh; } .dashboard_portfolio_summary_info { @@ -387,6 +389,16 @@ padding-bottom: 10px; } + .dashboard_portfolio_summary_container { + flex-direction: column; + align-items: center; + width: var(--dashboard-max-width); + } + + .dashboard_portfolio_summary { + width: var(--dashboard-max-width); + } + .quests_container { display: flex; flex-direction: column; From 44f3ba851e8e70bdb249f150efaf833b0ee5a74a Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Sun, 27 Oct 2024 23:10:08 +0100 Subject: [PATCH 3/6] feat: rework Argent service & improve chart visuals --- components/dashboard/PortfolioSummary.tsx | 62 ++++++------- .../skeletons/portfolioSummarySkeleton.tsx | 1 - services/argentPortfolioService.ts | 91 ++++++------------- styles/dashboard.module.css | 19 ++-- 4 files changed, 63 insertions(+), 110 deletions(-) diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx index cc8a8d53..4f5eee33 100644 --- a/components/dashboard/PortfolioSummary.tsx +++ b/components/dashboard/PortfolioSummary.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useCallback, useContext, useEffect, useState } from "react"; +import React, { FunctionComponent, useMemo } from "react"; import { TEXT_TYPE } from "@constants/typography"; import Typography from "@components/UI/typography/typography"; import { Doughnut } from "react-chartjs-2"; @@ -39,32 +39,36 @@ const ChartItem: FunctionComponent = ({ ); }; -const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol, isLoading }) => { +const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol }) => { const normalizeMinValue = (data: ChartItem[], minPercentage: number) => { - return data.map(entry => Number(entry.itemValue) < totalBalance * minPercentage ? (totalBalance * minPercentage).toFixed(2) : entry.itemValue) + return data.map(entry => + Number(entry.itemValue) < totalBalance * minPercentage ? + (totalBalance * minPercentage).toFixed(2) : + entry.itemValue + ); } - const chartOptions: ChartOptions<"doughnut"> = { + const chartOptions: ChartOptions<"doughnut"> = useMemo(() => ({ elements: { arc: { - borderAlign: "inner", borderRadius: 3, spacing: 1, - hoverOffset: 1, hoverBorderColor: "white", - hoverBorderWidth: 1 + hoverBorderWidth: 1, + hoverOffset: 2 } }, + layout: { + padding: 5 + }, responsive: true, maintainAspectRatio: false, + cutout: '65%', plugins: { tooltip: { - position: "nearest", - xAlign: "center", - yAlign: "top", callbacks: { label: function (tooltipItem: TooltipItem<"doughnut">) { - return `${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; + return ` ${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; } } } @@ -73,17 +77,15 @@ const PortfolioSummary: FunctionComponent = ({ title, dat let canvas = event.native?.target as HTMLCanvasElement; canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`; } - } + }), [data]); return data.length > 0 ? (
- - {title} - + {title}
- {isProtocol ? + {isProtocol && ( - : - <> - } + )}
-
- { - data.map((item, id) => ( - - )) - } +
+ {data.map((item, id) => ( + + ))}
-
+
entry.itemLabel), @@ -128,9 +120,7 @@ const PortfolioSummary: FunctionComponent = ({ title, dat
- ) : ( - <> - ); + ) : null; } -export default PortfolioSummary; +export default PortfolioSummary; \ No newline at end of file diff --git a/components/skeletons/portfolioSummarySkeleton.tsx b/components/skeletons/portfolioSummarySkeleton.tsx index 516f7d7e..29224d9e 100644 --- a/components/skeletons/portfolioSummarySkeleton.tsx +++ b/components/skeletons/portfolioSummarySkeleton.tsx @@ -1,6 +1,5 @@ import React, { FunctionComponent } from "react"; import { Skeleton } from "@mui/material"; -import styles from "@styles/dashboard.module.css"; const PortfolioSummarySkeleton: FunctionComponent = () => { return ( diff --git a/services/argentPortfolioService.ts b/services/argentPortfolioService.ts index cab1f540..41ec51ad 100644 --- a/services/argentPortfolioService.ts +++ b/services/argentPortfolioService.ts @@ -10,73 +10,45 @@ import { const API_BASE = "cloud.argent-api.com"; const API_VERSION = "v1"; +const API_HEADERS = { + "argent-client": "portfolio", + "argent-network": "mainnet", + "argent-version": "1.4.3", +}; -export const fetchDapps = async () => { +const fetchData = async (endpoint: string): Promise => { try { - const response = await fetch( - `https://${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet` - ); - const data: ArgentDapp[] = await response.json(); - - return Object.fromEntries( - data.map((dapp) => [dapp.dappId, dapp]) - ) as ArgentDappMap; + const response = await fetch(endpoint, { headers: API_HEADERS }); + if (!response.ok) { + throw new Error( + `Error ${response.status}: ${await response.text()}` + ); + } + return await response.json(); } catch (err) { - console.log("Error while fetching dapps from Argent API", err); - throw new Error("Error while fetching dapps from Argent API"); + console.log("Error fetching data from Argent API", err); + throw err; } }; -export const fetchTokens = async () => { - try { - const response = await fetch( - `https://${API_BASE}/${API_VERSION}/tokens/info?chain=starknet` - ); - const data: { tokens: [ArgentToken] } = await response.json(); +export const fetchDapps = async () => { + const data = await fetchData(`https://${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet`); + return Object.fromEntries(data.map((dapp) => [dapp.dappId, dapp])) as ArgentDappMap; +}; - return Object.fromEntries( - data.tokens.map((token) => [token.address, token]) - ) as ArgentTokenMap; - } catch (err) { - console.log("Error while fetching token from Argent API", err); - throw new Error("Error while fetching token from Argent API"); - } +export const fetchTokens = async () => { + const data = await fetchData<{ tokens: ArgentToken[] }>(`https://${API_BASE}/${API_VERSION}/tokens/info?chain=starknet`); + return Object.fromEntries(data.tokens.map((token) => [token.address, token])) as ArgentTokenMap; }; export const fetchUserTokens = async (walletAddress: string) => { - const opts = { - headers: { - "argent-client": "portfolio", - "argent-network": "mainnet", - "argent-version": "1.4.3", - }, - }; - - try { - const response = await fetch( - `https://${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`, - opts - ); - const data: { balances: ArgentUserToken[]; status: string } = - await response.json(); - return data.balances; - } catch (err) { - console.log("Error while fetching wallet dapps from Argent API", err); - throw new Error("Error while fetching wallet dapps from Argent API"); - } + const data = await fetchData<{ balances: ArgentUserToken[], status: string }>(`https://${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`); + return data.balances; }; export const fetchUserDapps = async (walletAddress: string) => { - try { - const response = await fetch( - `https://${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet` - ); - const data: { dapps: ArgentUserDapp[] } = await response.json(); - return data.dapps; - } catch (err) { - console.log("Error while fetching wallet dapps from Argent API", err); - throw new Error("Error while fetching wallet dapps from Argent API"); - } + const data = await fetchData<{ dapps: ArgentUserDapp[] }>(`https://${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet`); + return data.dapps; }; export const calculateTokenPrice = async ( @@ -84,14 +56,11 @@ export const calculateTokenPrice = async ( tokenAmount: Big.Big, currency: "USD" | "EUR" | "GBP" = "USD" ) => { + const data = await fetchData(`https://${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}`); try { - const response = await fetch( - `https://${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}` - ); - const data: ArgentTokenValue = await response.json(); return tokenAmount.mul(data.ccyValue).toNumber(); } catch (err) { - console.log("Error while fetching token price from Argent API", err); - throw new Error("Error while fetching token price from Argent API"); + console.log("Error while calculating token price", err); + throw err; } -}; \ No newline at end of file +}; diff --git a/styles/dashboard.module.css b/styles/dashboard.module.css index 6e7e2210..4594f81c 100644 --- a/styles/dashboard.module.css +++ b/styles/dashboard.module.css @@ -30,7 +30,6 @@ width: 100%; } - .profileCardLoading { position: absolute; top: 0; @@ -66,8 +65,6 @@ width: 2rem; } - - .blur1 { display: none; position: absolute; @@ -87,7 +84,6 @@ .dashboard_profile_card { display: flex; width: 100%; - /* max-width: var(--dashboard-max-width); */ padding: 1.5rem 2.5rem; background: linear-gradient(var(--background600), transparent); align-items: center; @@ -187,8 +183,6 @@ gap: 5px; } - - .right_middle { width: 100%; height: 100%; @@ -338,7 +332,7 @@ border-radius: 8px; padding: 24px; width: 100%; - max-height: 500px; + height: 200px; display: flex; flex-direction: row; background-color: var(--menu-background); @@ -368,7 +362,6 @@ margin-bottom: 0; } - .dashboard_wrapper { padding: 20px; padding-bottom: 30px; @@ -399,6 +392,12 @@ width: var(--dashboard-max-width); } + .dashboard_portfolio_summary_info { + flex-direction: column-reverse; + align-items: center; + height: fit-content; + } + .quests_container { display: flex; flex-direction: column; @@ -420,15 +419,11 @@ margin-bottom: 10px; } - - .center { gap: 0; padding-left: unset; } - - .address_div { justify-content: center; gap: 0.5rem; From 54b23ec63836d61445d7adcdececf3d7365967d6 Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Sun, 27 Oct 2024 23:27:55 +0100 Subject: [PATCH 4/6] refactor: revert changes to appIcon.tsx and imports --- components/{UI => discover}/appIcon.tsx | 0 components/discover/claimModal.tsx | 2 +- components/discover/defiTable.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename components/{UI => discover}/appIcon.tsx (100%) diff --git a/components/UI/appIcon.tsx b/components/discover/appIcon.tsx similarity index 100% rename from components/UI/appIcon.tsx rename to components/discover/appIcon.tsx diff --git a/components/discover/claimModal.tsx b/components/discover/claimModal.tsx index 869114c9..e8989d5a 100644 --- a/components/discover/claimModal.tsx +++ b/components/discover/claimModal.tsx @@ -6,7 +6,7 @@ import Typography from "@components/UI/typography/typography"; import Button from "@components/UI/button"; import { CDNImg } from "@components/cdn/image"; import { TEXT_TYPE } from "@constants/typography"; -import AppIcon from "../UI/appIcon"; +import AppIcon from "./appIcon"; import TokenIcon from "./tokenIcon"; import { useNotification } from "@context/NotificationProvider"; import Loading from "@app/loading"; diff --git a/components/discover/defiTable.tsx b/components/discover/defiTable.tsx index 6283f01f..1cf801ec 100644 --- a/components/discover/defiTable.tsx +++ b/components/discover/defiTable.tsx @@ -28,7 +28,7 @@ import { STABLES, TOKEN_OPTIONS, } from "@constants/defi"; -import AppIcon from "../UI/appIcon"; +import AppIcon from "./appIcon"; import ActionText from "./actionText"; import DownIcon from "@components/UI/iconsComponents/icons/downIcon"; import UpIcon from "@components/UI/iconsComponents/icons/upIcon"; From 43bda1adda5bb367308f15b8f2f7fb3f253eb980 Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Mon, 28 Oct 2024 00:32:52 +0100 Subject: [PATCH 5/6] refactor: rabbit suggestions --- app/[addressOrDomain]/page.tsx | 12 ++++++------ components/dashboard/PortfolioSummary.tsx | 8 ++++---- services/argentPortfolioService.ts | 12 ++++++------ styles/dashboard.module.css | 2 +- styles/globals.css | 2 +- utils/feltService.ts | 7 +++++++ 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app/[addressOrDomain]/page.tsx b/app/[addressOrDomain]/page.tsx index 7f1bc3a4..d09cd045 100644 --- a/app/[addressOrDomain]/page.tsx +++ b/app/[addressOrDomain]/page.tsx @@ -207,6 +207,7 @@ export default function Page({ params }: AddressOrDomainProps) { let debt: DebtStatus = { hasDebt: false, tokens: [] }; for (const dapp of userDapps) { + if (!dapp.products[0]) { return; } for (const position of dapp.products[0].positions) { for (const tokenAddress of Object.keys(position.totalBalances)) { const tokenBalance = Number(position.totalBalances[tokenAddress]); @@ -222,12 +223,12 @@ export default function Page({ params }: AddressOrDomainProps) { const handleDebt = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap) => { const debtStatus = userHasDebt(userDapps); - if (!debtStatus.hasDebt) { return; } + if (!debtStatus || !debtStatus.hasDebt) { return; } for await (const debt of debtStatus.tokens) { let value = Number(protocolsMap[debt.dappId].itemValue); value += await calculateTokenPrice( - debt.tokenAddress, + debt.tokenAddress, tokenToDecimal(debt.tokenBalance.toString(), tokens[debt.tokenAddress].decimals), "USD" @@ -265,6 +266,7 @@ export default function Page({ params }: AddressOrDomainProps) { if (protocolsMap[userDapp.dappId]) { continue; } // Ignore entry if already present in the map let protocolBalance = 0; + if (!userDapp.products[0]) { return; } for await (const position of userDapp.products[0].positions) { for await (const tokenAddress of Object.keys(position.totalBalances)) { protocolBalance += await calculateTokenPrice( @@ -285,7 +287,7 @@ export default function Page({ params }: AddressOrDomainProps) { } const sortProtocols = (protocolsMap: ChartItemMap) => { - return Object.values(protocolsMap).toSorted((a, b) => parseFloat(b.itemValue) - parseFloat(a.itemValue)); + return Object.values(protocolsMap).sort((a, b) => parseFloat(b.itemValue) - parseFloat(a.itemValue)); } const handleExtraProtocols = (sortedProtocols: ChartItem[]) => { @@ -513,7 +515,7 @@ export default function Page({ params }: AddressOrDomainProps) { {/* Portfolio charts */}
- {loadingProtocols ? ( + {loadingProtocols ? ( // Change for corresponding state ) : ( sum + Number(item.itemValue), 0)} isProtocol={false} - isLoading={false} /> )} {loadingProtocols ? ( @@ -532,7 +533,6 @@ export default function Page({ params }: AddressOrDomainProps) { data={portfolioProtocols} totalBalance={portfolioProtocols.reduce((sum, item) => sum + Number(item.itemValue), 0)} isProtocol={true} - isLoading={loadingProtocols} /> )}
diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx index 4f5eee33..9fee73df 100644 --- a/components/dashboard/PortfolioSummary.tsx +++ b/components/dashboard/PortfolioSummary.tsx @@ -15,11 +15,10 @@ type PortfolioSummaryProps = { title: string, data: ChartItem[], totalBalance: number, - isProtocol: boolean, - isLoading: boolean + isProtocol: boolean } -const ChartItem: FunctionComponent = ({ +const ChartEntry: FunctionComponent = ({ color, itemLabel, itemValue, @@ -40,6 +39,7 @@ const ChartItem: FunctionComponent = ({ }; const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol }) => { + const normalizeMinValue = (data: ChartItem[], minPercentage: number) => { return data.map(entry => Number(entry.itemValue) < totalBalance * minPercentage ? @@ -100,7 +100,7 @@ const PortfolioSummary: FunctionComponent = ({ title, dat
{data.map((item, id) => ( - + ))}
diff --git a/services/argentPortfolioService.ts b/services/argentPortfolioService.ts index 41ec51ad..01faf19e 100644 --- a/services/argentPortfolioService.ts +++ b/services/argentPortfolioService.ts @@ -8,7 +8,7 @@ import { ArgentUserToken, } from "types/backTypes"; -const API_BASE = "cloud.argent-api.com"; +const API_BASE = "https://cloud.argent-api.com"; const API_VERSION = "v1"; const API_HEADERS = { "argent-client": "portfolio", @@ -32,22 +32,22 @@ const fetchData = async (endpoint: string): Promise => { }; export const fetchDapps = async () => { - const data = await fetchData(`https://${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet`); + const data = await fetchData(`${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet`); return Object.fromEntries(data.map((dapp) => [dapp.dappId, dapp])) as ArgentDappMap; }; export const fetchTokens = async () => { - const data = await fetchData<{ tokens: ArgentToken[] }>(`https://${API_BASE}/${API_VERSION}/tokens/info?chain=starknet`); + const data = await fetchData<{ tokens: ArgentToken[] }>(`${API_BASE}/${API_VERSION}/tokens/info?chain=starknet`); return Object.fromEntries(data.tokens.map((token) => [token.address, token])) as ArgentTokenMap; }; export const fetchUserTokens = async (walletAddress: string) => { - const data = await fetchData<{ balances: ArgentUserToken[], status: string }>(`https://${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`); + const data = await fetchData<{ balances: ArgentUserToken[], status: string }>(`${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`); return data.balances; }; export const fetchUserDapps = async (walletAddress: string) => { - const data = await fetchData<{ dapps: ArgentUserDapp[] }>(`https://${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet`); + const data = await fetchData<{ dapps: ArgentUserDapp[] }>(`${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet`); return data.dapps; }; @@ -56,7 +56,7 @@ export const calculateTokenPrice = async ( tokenAmount: Big.Big, currency: "USD" | "EUR" | "GBP" = "USD" ) => { - const data = await fetchData(`https://${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}`); + const data = await fetchData(`${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}`); try { return tokenAmount.mul(data.ccyValue).toNumber(); } catch (err) { diff --git a/styles/dashboard.module.css b/styles/dashboard.module.css index 4594f81c..0a89662c 100644 --- a/styles/dashboard.module.css +++ b/styles/dashboard.module.css @@ -389,7 +389,7 @@ } .dashboard_portfolio_summary { - width: var(--dashboard-max-width); + width: 90%; } .dashboard_portfolio_summary_info { diff --git a/styles/globals.css b/styles/globals.css index d0821815..0bba5350 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -34,7 +34,7 @@ --white: #fff; --textGray: #a6a5a7; --transparent: transparent; - --dashboard-max-width: 90%; + --dashboard-max-width: clamp(320px, 90%, 1500px); } html, diff --git a/utils/feltService.ts b/utils/feltService.ts index 021a31b8..282ca64a 100644 --- a/utils/feltService.ts +++ b/utils/feltService.ts @@ -38,6 +38,13 @@ export function gweiToEth(gwei: string): string { } export function tokenToDecimal(tokenValue: string, decimals: number) { + if (!tokenValue || isNaN(Number(tokenValue))) { + return new Big(0); + } + if (decimals < 0) { + throw new Error("Decimals must be non-negative"); + } + const tokenValueBigInt = new Big(tokenValue); const scaleFactor = new Big(10 ** decimals); From 7dff03b6bbed0eff48539a72c2ae8f385e3d1e57 Mon Sep 17 00:00:00 2001 From: joeperpetua Date: Mon, 28 Oct 2024 09:21:16 +0100 Subject: [PATCH 6/6] refactor: add tests & standarize tokenToDecimal fn --- app/[addressOrDomain]/page.tsx | 2 +- components/dashboard/PortfolioSummary.tsx | 15 ++++++++------- services/argentPortfolioService.ts | 4 ++-- tests/utils/feltService.test.js | 20 ++++++++++++++++++++ utils/feltService.ts | 9 +++------ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/app/[addressOrDomain]/page.tsx b/app/[addressOrDomain]/page.tsx index d09cd045..a41c9386 100644 --- a/app/[addressOrDomain]/page.tsx +++ b/app/[addressOrDomain]/page.tsx @@ -207,7 +207,7 @@ export default function Page({ params }: AddressOrDomainProps) { let debt: DebtStatus = { hasDebt: false, tokens: [] }; for (const dapp of userDapps) { - if (!dapp.products[0]) { return; } + if (!dapp.products[0]) { continue; } for (const position of dapp.products[0].positions) { for (const tokenAddress of Object.keys(position.totalBalances)) { const tokenBalance = Number(position.totalBalances[tokenAddress]); diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx index 9fee73df..733c4035 100644 --- a/components/dashboard/PortfolioSummary.tsx +++ b/components/dashboard/PortfolioSummary.tsx @@ -15,7 +15,8 @@ type PortfolioSummaryProps = { title: string, data: ChartItem[], totalBalance: number, - isProtocol: boolean + isProtocol: boolean, + minSlicePercentage?: number } const ChartEntry: FunctionComponent = ({ @@ -38,12 +39,12 @@ const ChartEntry: FunctionComponent = ({ ); }; -const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol }) => { - - const normalizeMinValue = (data: ChartItem[], minPercentage: number) => { +const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol, minSlicePercentage = 0.05 }) => { + + const normalizeMinValue = (data: ChartItem[]) => { return data.map(entry => - Number(entry.itemValue) < totalBalance * minPercentage ? - (totalBalance * minPercentage).toFixed(2) : + Number(entry.itemValue) < totalBalance * minSlicePercentage ? + (totalBalance * minSlicePercentage).toFixed(2) : entry.itemValue ); } @@ -109,7 +110,7 @@ const PortfolioSummary: FunctionComponent = ({ title, dat labels: data.map(entry => entry.itemLabel), datasets: [{ label: '', - data: normalizeMinValue(data, .05), + data: normalizeMinValue(data), backgroundColor: data.map(entry => entry.color), borderColor: data.map(entry => entry.color), borderWidth: 1, diff --git a/services/argentPortfolioService.ts b/services/argentPortfolioService.ts index 01faf19e..cbff1719 100644 --- a/services/argentPortfolioService.ts +++ b/services/argentPortfolioService.ts @@ -53,12 +53,12 @@ export const fetchUserDapps = async (walletAddress: string) => { export const calculateTokenPrice = async ( tokenAddress: string, - tokenAmount: Big.Big, + tokenAmount: string, currency: "USD" | "EUR" | "GBP" = "USD" ) => { const data = await fetchData(`${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}`); try { - return tokenAmount.mul(data.ccyValue).toNumber(); + return Number(tokenAmount) * Number(data.ccyValue); } catch (err) { console.log("Error while calculating token price", err); throw err; diff --git a/tests/utils/feltService.test.js b/tests/utils/feltService.test.js index 1df2a958..6b0d1898 100644 --- a/tests/utils/feltService.test.js +++ b/tests/utils/feltService.test.js @@ -3,6 +3,7 @@ import { decimalToHex, stringToHex, gweiToEth, + tokenToDecimal } from "@utils/feltService"; describe("Should test hexToDecimal function", () => { @@ -75,3 +76,22 @@ describe("Should test gweiToEth function", () => { expect(gweiToEth("")).toEqual("0"); }); }); + +describe("Should test tokenToDecimal function", () => { + it("Should return the right decimal value from a given token with dynamic decimals", () => { + expect(tokenToDecimal("113623892493328485", 18)).toEqual("0.11362"); + expect(tokenToDecimal("3477473", 6)).toEqual("3.47747"); + }); + + it("Should return 0 if the value is an empty string", () => { + expect(tokenToDecimal("", 6)).toEqual("0"); + }); + + it("Should return 0 if the decimal is not a valid number", () => { + expect(tokenToDecimal("8943032", 'hello')).toEqual("0"); + }); + + it("Should return 0 if the decimal is a negative number", () => { + expect(tokenToDecimal("22341256543", -5)).toEqual("0"); + }); +}); \ No newline at end of file diff --git a/utils/feltService.ts b/utils/feltService.ts index 282ca64a..e4217b26 100644 --- a/utils/feltService.ts +++ b/utils/feltService.ts @@ -38,11 +38,8 @@ export function gweiToEth(gwei: string): string { } export function tokenToDecimal(tokenValue: string, decimals: number) { - if (!tokenValue || isNaN(Number(tokenValue))) { - return new Big(0); - } - if (decimals < 0) { - throw new Error("Decimals must be non-negative"); + if (!tokenValue || isNaN(Number(tokenValue)) || isNaN(Number(decimals)) || decimals < 0) { + return "0"; } const tokenValueBigInt = new Big(tokenValue); @@ -50,5 +47,5 @@ export function tokenToDecimal(tokenValue: string, decimals: number) { const tokenDecimal = tokenValueBigInt.div(scaleFactor).round(5); - return tokenDecimal; + return tokenDecimal.toString(); }