diff --git a/apps/hyperdrive-trading/package.json b/apps/hyperdrive-trading/package.json index 28b09ef63..37f8a4123 100644 --- a/apps/hyperdrive-trading/package.json +++ b/apps/hyperdrive-trading/package.json @@ -45,6 +45,7 @@ "@tanstack/query-core": "^4.36.1", "@types/d3-format": "^3.0.4", "@types/lodash.sortby": "^4.7.9", + "@types/lodash.groupby": "^4.6.9", "@uniswap/token-lists": "^1.0.0-beta.34", "@usecapsule/rainbowkit-wallet": "^0.9.4", "@usecapsule/react-sdk": "^3.17.0", @@ -61,6 +62,7 @@ "graphql": "^16.9.0", "graphql-request": "^7.1.0", "lodash.sortby": "^4.7.0", + "lodash.groupby": "^4.6.0", "process": "^0.11.10", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/apps/hyperdrive-trading/src/ui/portfolio/PortfolioTableHeading.tsx b/apps/hyperdrive-trading/src/ui/portfolio/PortfolioTableHeading.tsx new file mode 100644 index 000000000..61ade2181 --- /dev/null +++ b/apps/hyperdrive-trading/src/ui/portfolio/PortfolioTableHeading.tsx @@ -0,0 +1,18 @@ +import { ReactElement, ReactNode } from "react"; + +export function PortfolioTableHeading({ + leftElement, + rightElement, +}: { + leftElement: ReactNode; + rightElement: ReactNode; +}): ReactElement { + return ( +
+
+ {leftElement} +
+ {rightElement} +
+ ); +} diff --git a/apps/hyperdrive-trading/src/ui/portfolio/PositionTableHeading.tsx b/apps/hyperdrive-trading/src/ui/portfolio/PositionTableHeading.tsx index a48720639..c7832f6eb 100644 --- a/apps/hyperdrive-trading/src/ui/portfolio/PositionTableHeading.tsx +++ b/apps/hyperdrive-trading/src/ui/portfolio/PositionTableHeading.tsx @@ -1,6 +1,7 @@ import { HyperdriveConfig } from "@delvtech/hyperdrive-appconfig"; import { ReactElement, ReactNode } from "react"; import { AssetStack } from "src/ui/markets/AssetStack"; +import { PortfolioTableHeading } from "src/ui/portfolio/PortfolioTableHeading"; export function PositionTableHeading({ hyperdrive, @@ -15,15 +16,17 @@ export function PositionTableHeading({ rightElement: ReactNode; }): ReactElement { return ( -
-
- -

{hyperdriveName ?? hyperdrive.name}

-
- {rightElement} -
+ + + {hyperdriveName ?? hyperdrive.name} + + } + rightElement={rightElement} + /> ); } diff --git a/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsContainer.tsx b/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsContainer.tsx index 414424731..2fec0f1cd 100644 --- a/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsContainer.tsx +++ b/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsContainer.tsx @@ -1,15 +1,19 @@ +import { appConfig } from "@delvtech/hyperdrive-appconfig"; import { Link } from "@tanstack/react-router"; +import groupBy from "lodash.groupby"; import { ReactElement } from "react"; import LoadingState from "src/ui/base/components/LoadingState"; import { NonIdealState } from "src/ui/base/components/NonIdealState"; import { NoWalletConnected } from "src/ui/portfolio/NoWalletConnected"; +import { PortfolioTableHeading } from "src/ui/portfolio/PortfolioTableHeading"; import { PositionContainer } from "src/ui/portfolio/PositionContainer"; -import { useRewardsData } from "src/ui/portfolio/rewards/useRewardsData"; +import { RewardsTableDesktop } from "src/ui/portfolio/rewards/RewardsTableDesktop"; +import { usePortfolioRewardsData } from "src/ui/portfolio/rewards/useRewardsData"; import { useAccount } from "wagmi"; export function RewardsContainer(): ReactElement { const { address: account } = useAccount(); - const { rewards, rewardsStatus } = useRewardsData({ account }); + const { rewards, rewardsStatus } = usePortfolioRewardsData({ account }); if (!account) { return ; @@ -37,7 +41,7 @@ export function RewardsContainer(): ReactElement { ); } - const hasClaimableRewards = rewards?.some((reward) => reward); + const hasClaimableRewards = !rewards || rewards?.some((reward) => reward); if (!hasClaimableRewards) { return ( @@ -59,5 +63,29 @@ export function RewardsContainer(): ReactElement { ); } - return TODO; + const rewardsByChain = groupBy(rewards, (reward) => reward.chainId); + + return ( + + {Object.entries(rewardsByChain).map(([chainId, rewards]) => { + const chainInfo = appConfig.chains[+chainId]; + return ( +
+ +
+ +
+ {chainInfo.name} Rewards +
+ } + rightElement={null} + /> + + + ); + })} +
+ ); } diff --git a/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsTableDesktop.tsx b/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsTableDesktop.tsx new file mode 100644 index 000000000..7fdb097e6 --- /dev/null +++ b/apps/hyperdrive-trading/src/ui/portfolio/rewards/RewardsTableDesktop.tsx @@ -0,0 +1,188 @@ +import { appConfig, getToken } from "@delvtech/hyperdrive-appconfig"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import classNames from "classnames"; +import { ReactElement } from "react"; +import { Pagination } from "src/ui/base/components/Pagination"; +import { formatBalance } from "src/ui/base/formatting/formatBalance"; +import { Reward } from "src/ui/portfolio/rewards/useRewardsData"; +import { Address } from "viem"; + +export function RewardsTableDesktop({ + account, + rewards, +}: { + account: Address; + rewards: Reward[] | undefined; +}): ReactElement { + const tableInstance = useReactTable({ + columns: getColumns(), + data: rewards || [], + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ + + {tableInstance.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, headerIndex) => ( + + ))} + + ))} + + + + {tableInstance.getRowModel().rows.map((row, index) => { + const isLastRow = + index === tableInstance.getRowModel().rows.length - 1; + return ( + + {row.getVisibleCells().map((cell, cellIndex) => ( + + ))} + + ); + })} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+ {/* Custom border with inset for the first and last header cells */} + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + {!isLastRow && ( + + )} +
+ {tableInstance.getFilteredRowModel().rows.length > 10 ? ( + + ) : null} +
+ ); +} + +const columnHelper = createColumnHelper(); + +function getColumns() { + return [ + columnHelper.display({ + id: "asset", + header: "Asset", + cell: ({ row }) => { + const token = getToken({ + appConfig, + chainId: row.original.chainId, + tokenAddress: row.original.rewardToken, + })!; + return ( +
+ + {token.name} +
+ ); + }, + }), + columnHelper.display({ + id: "claimable", + header: "Claimable", + cell: ({ row }) => { + const token = getToken({ + appConfig, + chainId: row.original.chainId, + tokenAddress: row.original.rewardToken, + })!; + return ( +
+ + {formatBalance({ + balance: row.original.claimable || 0n, + decimals: token.decimals, + places: token.places, + })}{" "} + {token.symbol} + +
+ ); + }, + }), + columnHelper.display({ + id: "claim", + cell: ({ row }) => { + return ( + + ); + }, + }), + ]; +} diff --git a/apps/hyperdrive-trading/src/ui/portfolio/rewards/useRewardsData.ts b/apps/hyperdrive-trading/src/ui/portfolio/rewards/useRewardsData.ts index 47ad0054a..2567da650 100644 --- a/apps/hyperdrive-trading/src/ui/portfolio/rewards/useRewardsData.ts +++ b/apps/hyperdrive-trading/src/ui/portfolio/rewards/useRewardsData.ts @@ -1,9 +1,72 @@ +import { parseFixed } from "@delvtech/fixed-point-wasm"; +import { appConfig } from "@delvtech/hyperdrive-appconfig"; import { useQuery } from "@tanstack/react-query"; import { makeQueryKey } from "src/base/makeQueryKey"; -import { Address } from "viem"; +import { Address, zeroAddress } from "viem"; +import { base } from "viem/chains"; -export function useRewardsData({ account }: { account: Address | undefined }): { - rewards: unknown[] | undefined; +interface RewardsResponse { + userAddress: Address; + rewards: Reward[]; +} + +export interface Reward { + chainId: number; + claimContract: Address; + claimable: bigint; + total: bigint; + claimed: bigint; + claimableLastUpdated: number; + rewardToken: Address; + merkleProof: string[] | null; + merkleProofLastUpdated: number; +} + +function getDummyRewardsResponse(account: Address) { + const dummyRewardsResponse: RewardsResponse = { + userAddress: account, + rewards: [ + { + // rewards for this user that they can claim + chainId: base.id, + claimContract: zeroAddress, + claimable: parseFixed("1000000").bigint, + total: parseFixed("1000000").bigint, + claimed: parseFixed("0").bigint, + claimableLastUpdated: 123456789, + rewardToken: appConfig.tokens.find( + (token) => token.chainId === 8453 && token.symbol === "MORPHO", + )!.address, + merkleProof: ["0xProof", "0xProof", "0xProof"], + merkleProofLastUpdated: 123892327, + }, + { + // rewards are accumulating, but the merkle root hasn't been added + // to the claimContract yet + chainId: base.id, + claimContract: zeroAddress, + total: parseFixed("1000000").bigint, + claimed: parseFixed("0").bigint, + claimable: parseFixed("0").bigint, + claimableLastUpdated: 123456789, + rewardToken: appConfig.tokens.find( + (token) => token.chainId === 8453 && token.symbol === "USDC", + )!.address, + merkleProof: null, + merkleProofLastUpdated: 123892327, + }, + ], + }; + + return dummyRewardsResponse; +} + +export function usePortfolioRewardsData({ + account, +}: { + account: Address | undefined; +}): { + rewards: Reward[] | undefined; rewardsStatus: "error" | "success" | "loading"; } { const queryEnabled = !!account; @@ -12,7 +75,8 @@ export function useRewardsData({ account }: { account: Address | undefined }): { queryFn: queryEnabled ? async () => { // TODO: Fetch rewards from server - return []; + const rewardsResponse = getDummyRewardsResponse(account); + return rewardsResponse.rewards; } : undefined, enabled: queryEnabled,