From 8c873512024a586b9cec4c0009946a8e7b704213 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 14:15:59 +0800 Subject: [PATCH 01/15] feat: remove unncessary buttons and update history --- src/components/layout/header/Navbar.tsx | 9 + .../history/components/history-table.tsx | 286 ++++++++++++------ .../components/onboarding/asset-selection.tsx | 103 +++---- .../onboarding/onboarding-modal.tsx | 1 - .../positions/components/onboarding/types.ts | 2 - .../components/position-actions-dropdown.tsx | 81 +++++ .../components/positions-summary-table.tsx | 18 +- src/features/positions/positions-view.tsx | 28 +- 8 files changed, 346 insertions(+), 182 deletions(-) create mode 100644 src/features/positions/components/position-actions-dropdown.tsx diff --git a/src/components/layout/header/Navbar.tsx b/src/components/layout/header/Navbar.tsx index 7a3b182d..6a09db65 100644 --- a/src/components/layout/header/Navbar.tsx +++ b/src/components/layout/header/Navbar.tsx @@ -11,6 +11,7 @@ import { FaRegMoon } from 'react-icons/fa'; import { FiSettings } from 'react-icons/fi'; import { LuSunMedium } from 'react-icons/lu'; import { RiBookLine, RiDiscordFill, RiGithubFill } from 'react-icons/ri'; +import { TbReport } from 'react-icons/tb'; import { useConnection } from 'wagmi'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { EXTERNAL_LINKS } from '@/utils/external'; @@ -157,6 +158,14 @@ export function Navbar() { > GitHub + {mounted && address && ( + } + onClick={() => router.push(`/positions/report/${address}`)} + > + Report + + )} : )} onClick={toggleTheme} diff --git a/src/features/history/components/history-table.tsx b/src/features/history/components/history-table.tsx index b1159a2b..59cebc4e 100644 --- a/src/features/history/components/history-table.tsx +++ b/src/features/history/components/history-table.tsx @@ -1,22 +1,21 @@ import type React from 'react'; import { useMemo, useState, useRef, useEffect } from 'react'; import Link from 'next/link'; -import { Badge as Chip } from '@/components/ui/badge'; +import { useSearchParams } from 'next/navigation'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from '@/components/ui/table'; import { ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons'; -import moment from 'moment'; import Image from 'next/image'; import { RiRobot2Line } from 'react-icons/ri'; import { formatUnits } from 'viem'; -import { Badge } from '@/components/ui/badge'; import { TablePagination } from '@/components/shared/table-pagination'; import { TransactionIdentity } from '@/components/shared/transaction-identity'; import LoadingScreen from '@/components/status/loading-screen'; import { TokenIcon } from '@/components/shared/token-icon'; +import { MarketIdentity, MarketIdentityFocus } from '@/features/markets/components/market-identity'; +import { getTruncatedAssetName } from '@/utils/oracle'; import { useMarkets } from '@/contexts/MarketsContext'; import useUserTransactions from '@/hooks/useUserTransactions'; import { formatReadable } from '@/utils/balance'; -import { actionTypeToText } from '@/utils/morpho'; import { getNetworkImg, getNetworkName } from '@/utils/networks'; import { UserTxTypes, type UserRebalancerInfo, type Market, type MarketPosition, type UserTransaction } from '@/utils/types'; @@ -33,10 +32,35 @@ type AssetKey = { decimals: number; }; +const formatTimeAgo = (timestamp: number): string => { + const now = Date.now(); + const txTime = timestamp * 1000; + const diffInSeconds = Math.floor((now - txTime) / 1000); + + if (diffInSeconds < 60) return `${diffInSeconds}s ago`; + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) return `${diffInHours}h ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 30) return `${diffInDays}d ago`; + + const diffInMonths = Math.floor(diffInDays / 30); + if (diffInMonths < 12) return `${diffInMonths}mo ago`; + + const diffInYears = Math.floor(diffInMonths / 12); + return `${diffInYears}y ago`; +}; + export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTableProps) { + const searchParams = useSearchParams(); const [selectedAsset, setSelectedAsset] = useState(null); const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(''); + const [hasInitializedFromUrl, setHasInitializedFromUrl] = useState(false); const dropdownRef = useRef(null); const { allMarkets } = useMarkets(); @@ -67,6 +91,55 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab return Array.from(assetMap.values()); }, [positions, allMarkets]); + // Handle initial URL parameters for pre-filtering (only once) + useEffect(() => { + // Only initialize once and only if we have URL params + if (hasInitializedFromUrl) return; + + const chainIdParam = searchParams.get('chainId'); + const tokenAddressParam = searchParams.get('tokenAddress'); + const tokenSymbolParam = searchParams.get('tokenSymbol'); + + // If no URL params, we're done initializing + if (!chainIdParam || !tokenAddressParam) { + setHasInitializedFromUrl(true); + return; + } + + // Wait for markets to load before initializing + if (allMarkets.length === 0) return; + + const chainId = Number.parseInt(chainIdParam, 10); + + // Try to find in uniqueAssets first (from user positions) + const matchingAsset = uniqueAssets.find( + (asset) => asset.chainId === chainId && asset.address.toLowerCase() === tokenAddressParam.toLowerCase(), + ); + + if (matchingAsset) { + setSelectedAsset(matchingAsset); + setHasInitializedFromUrl(true); + } else if (tokenSymbolParam) { + // If not in positions, create from URL params (user might not have position but coming from link) + const decimals = + allMarkets.find((m) => m.morphoBlue.chain.id === chainId && m.loanAsset.address.toLowerCase() === tokenAddressParam.toLowerCase()) + ?.loanAsset.decimals ?? 18; + + setSelectedAsset({ + symbol: tokenSymbolParam, + chainId, + address: tokenAddressParam, + decimals, + }); + setHasInitializedFromUrl(true); + } + }, [searchParams, uniqueAssets, allMarkets, hasInitializedFromUrl]); + + // Reset page when filter changes + useEffect(() => { + setCurrentPage(1); + }, [selectedAsset]); + // Get filtered market IDs based on selected asset const marketIdFilter = useMemo(() => { if (!selectedAsset) return []; @@ -123,11 +196,11 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab return (
{isOpen && ( -
+
- - Asset & Network - Market Details - Action & Amount - Time - Transaction + + + Loan Asset + + + Market + + + Side + + + Amount + + + Tx Hash + + + Time + - + {history.filter((tx) => tx.data.market !== undefined).length === 0 ? ( No transactions found @@ -283,10 +387,8 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab // safely cast here because we only fetch txs for unique id in "markets" const market = allMarkets.find((m) => m.uniqueKey === tx.data.market.uniqueKey) as Market; - const networkImg = getNetworkImg(market.morphoBlue.chain.id); - const networkName = getNetworkName(market.morphoBlue.chain.id); const sign = tx.type === UserTxTypes.MarketSupply ? '+' : '-'; - const lltv = Number(formatUnits(BigInt(market.lltv), 18)) * 100; + const side = tx.type === UserTxTypes.MarketSupply ? 'Supply' : 'Withdraw'; // Find the rebalancer info for the specific network of the transaction const networkRebalancerInfo = rebalancerInfos.find((info) => info.network === market.morphoBlue.chain.id); @@ -294,94 +396,106 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab const isAgent = networkRebalancerInfo?.transactions.some((agentTx) => agentTx.transactionHash === tx.hash); return ( - - {/* Network & Asset */} - -
-
- - {market.loanAsset.symbol} -
-
- {networkImg && ( - - )} - {networkName} -
+ + {/* Loan Asset */} + +
+ + {getTruncatedAssetName(market.loanAsset.symbol)}
- {/* Market Details */} - -
- - {market.uniqueKey.slice(2, 8)} - -
- + +
+ - {market.collateralAsset.symbol}
- - {formatReadable(lltv)}% - -
+ - {/* Action & Amount */} - -
- {actionTypeToText(tx.type)} - - {sign} - {formatReadable(Number(formatUnits(BigInt(tx.data.assets), market.loanAsset.decimals)))}{' '} - {market.loanAsset.symbol} - + {/* Side */} + + + {side} {isAgent && ( - - - + )} -
+
- {/* Time */} - -
{moment.unix(tx.timestamp).fromNow()}
+ {/* Amount */} + + + {sign} + {formatReadable(Number(formatUnits(BigInt(tx.data.assets), market.loanAsset.decimals)))}{' '} + {getTruncatedAssetName(market.loanAsset.symbol)} + - {/* Transaction */} - -
+ {/* Transaction Hash */} + +
+ + {/* Time */} + + {formatTimeAgo(tx.timestamp)} + ); }) diff --git a/src/features/positions/components/onboarding/asset-selection.tsx b/src/features/positions/components/onboarding/asset-selection.tsx index c682b59e..d8e5ddaf 100644 --- a/src/features/positions/components/onboarding/asset-selection.tsx +++ b/src/features/positions/components/onboarding/asset-selection.tsx @@ -1,14 +1,14 @@ import { useMemo } from 'react'; -import { motion } from 'framer-motion'; import Image from 'next/image'; import Link from 'next/link'; import { formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; import { useMarkets } from '@/hooks/useMarkets'; import { useUserBalancesAllNetworks } from '@/hooks/useUserBalances'; import { formatReadable } from '@/utils/balance'; -import { getNetworkImg, getNetworkName } from '@/utils/networks'; +import { getNetworkImg } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; import { useOnboarding } from './onboarding-context'; import type { TokenWithMarkets } from './types'; @@ -45,11 +45,6 @@ export function AssetSelection() { if (relevantMarkets.length === 0) return; - // Calculate min and max APY - const apys = relevantMarkets.map((market) => market.state.supplyApy); - const minApy = Math.max(Math.min(...apys), 0); - const maxApy = Math.max(Math.max(...apys), 0); - // Get network name const network = balance.chainId; @@ -59,8 +54,6 @@ export function AssetSelection() { result.push({ symbol: balance.symbol, markets: relevantMarkets, - minApy, - maxApy, logoURI: token.img, decimals: balance.decimals, network, @@ -80,76 +73,72 @@ export function AssetSelection() { if (balancesLoading || marketsLoading) { return ( -
-
+
+
+

+ {balancesLoading && marketsLoading + ? 'Loading token balances and markets...' + : balancesLoading + ? 'Fetching your token balances across networks' + : 'Loading available markets...'} +

); } return ( -
+
{tokensWithMarkets.length === 0 ? ( -
-

No assets available

-

You need to have some assets in your wallet to supply

+
+
+ 💰 +
+

No Assets Found

+

You need to have some assets in your wallet to supply

) : ( -
+
{tokensWithMarkets.map((token, idx) => ( - handleTokenSelect(token)} - className="group relative flex items-start gap-4 rounded border border-gray-200 bg-white p-4 text-left transition-all duration-300 hover:border-primary dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800" - transition={{ type: 'spring', stiffness: 300, damping: 20 }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleTokenSelect(token); + } + }} + role="button" + tabIndex={0} > -
- {token.logoURI && ( - +
+ - )} -
-
-
-
-

{token.symbol}

-
- - {getNetworkName(token.network)} -
-
+ + {formatReadable(formatUnits(BigInt(token.balance), token.decimals))} {token.symbol} +
-
-

- Balance: {formatReadable(formatUnits(BigInt(token.balance), token.decimals))} {token.symbol} -

-
-
-

- {token.markets.length} market - {token.markets.length !== 1 ? 's' : ''} -

- -

- {(token.minApy * 100).toFixed(2)}% - {(token.maxApy * 100).toFixed(2)}% APY -

-
-
+
+ + + {token.markets.length} market + {token.markets.length !== 1 ? 's' : ''} +
- +
))}
)} diff --git a/src/features/positions/components/onboarding/onboarding-modal.tsx b/src/features/positions/components/onboarding/onboarding-modal.tsx index 009a840a..ab63d115 100644 --- a/src/features/positions/components/onboarding/onboarding-modal.tsx +++ b/src/features/positions/components/onboarding/onboarding-modal.tsx @@ -24,7 +24,6 @@ export function OnboardingModal({ isOpen, onOpenChange }: { isOpen: boolean; onO flexibleWidth scrollBehavior="inside" backdrop="blur" - className="bg-surface" > void; +}; + +export function PositionActionsDropdown({ + account, + chainId, + tokenAddress, + tokenSymbol, + isOwner, + onRebalanceClick, +}: PositionActionsDropdownProps) { + const router = useRouter(); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Stop propagation on keyboard events too + e.stopPropagation(); + }; + + const handleHistoryClick = () => { + const historyUrl = `/history/${account}?chainId=${chainId}&tokenAddress=${tokenAddress}&tokenSymbol=${tokenSymbol}`; + router.push(historyUrl); + }; + + return ( +
+ + + + + + } + disabled={!isOwner} + className={isOwner ? '' : 'opacity-50 cursor-not-allowed'} + > + Rebalance + + + } + > + History + + + +
+ ); +} diff --git a/src/features/positions/components/positions-summary-table.tsx b/src/features/positions/components/positions-summary-table.tsx index d10132c0..23772f17 100644 --- a/src/features/positions/components/positions-summary-table.tsx +++ b/src/features/positions/components/positions-summary-table.tsx @@ -35,6 +35,7 @@ import { WarningCategory, } from '@/utils/types'; import { RiskIndicator } from '@/features/markets/components/risk-indicator'; +import { PositionActionsDropdown } from './position-actions-dropdown'; import { RebalanceModal } from './rebalance/rebalance-modal'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; @@ -415,11 +416,13 @@ export function PositionsSummaryTable({ className="justify-center px-4 py-3" >
- + />
diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index e4cbc817..3f8506b3 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -1,12 +1,10 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; import { Tooltip } from '@/components/ui/tooltip'; -import { FaHistory, FaPlus } from 'react-icons/fa'; +import { FaPlus } from 'react-icons/fa'; import { IoRefreshOutline } from 'react-icons/io5'; -import { TbReport } from 'react-icons/tb'; import { toast } from 'react-toastify'; import type { Address } from 'viem'; import { useConnection } from 'wagmi'; @@ -87,30 +85,6 @@ export default function Positions() { showAddress />
- - - - - - {isOwner && ( - e.preventDefault()} - > -
- Show Empty Positions - -
-
e.preventDefault()} @@ -453,7 +433,6 @@ export function PositionsSummaryTable({ setShowWithdrawModal={setShowWithdrawModal} setShowSupplyModal={setShowSupplyModal} setSelectedPosition={setSelectedPosition} - showEmptyPositions={showEmptyPositions} showCollateralExposure={showCollateralExposure} /> diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx index a898eb22..27cbbe32 100644 --- a/src/features/positions/components/supplied-markets-detail.tsx +++ b/src/features/positions/components/supplied-markets-detail.tsx @@ -14,7 +14,6 @@ type SuppliedMarketsDetailProps = { setShowWithdrawModal: (show: boolean) => void; setShowSupplyModal: (show: boolean) => void; setSelectedPosition: (position: MarketPosition) => void; - showEmptyPositions: boolean; showCollateralExposure: boolean; }; @@ -136,23 +135,17 @@ export function SuppliedMarketsDetail({ setShowWithdrawModal, setShowSupplyModal, setSelectedPosition, - showEmptyPositions, showCollateralExposure, }: SuppliedMarketsDetailProps) { const { short: rateLabel } = useRateLabel(); - // Sort active markets by size first + // Sort markets by size const sortedMarkets = [...groupedPosition.markets].sort( (a, b) => Number(formatBalance(b.state.supplyAssets, b.market.loanAsset.decimals)) - Number(formatBalance(a.state.supplyAssets, a.market.loanAsset.decimals)), ); - // Filter based on the showEmptyPositions prop - const filteredMarkets = showEmptyPositions - ? sortedMarkets - : sortedMarkets.filter((position) => Number(formatBalance(position.state.supplyAssets, position.market.loanAsset.decimals)) > 0); - const totalSupply = groupedPosition.totalSupply; return ( @@ -217,7 +210,7 @@ export function SuppliedMarketsDetail({ - {filteredMarkets.map((position) => ( + {sortedMarkets.map((position) => ( Date: Mon, 22 Dec 2025 14:49:54 +0800 Subject: [PATCH 03/15] feat: summary table --- docs/ARCHITECTURE.md | 3 +- docs/Styling.md | 117 +++++++++ .../common/table-container-with-header.tsx | 42 ++++ .../admin/components/asset-metrics-table.tsx | 232 +++++++++--------- .../autovault/components/vault-list.tsx | 2 +- src/features/autovault/vault-view.tsx | 2 +- .../components/collateral-icons-display.tsx | 123 ++++++++++ ...=> supplied-morpho-blue-grouped-table.tsx} | 197 +++++++-------- src/features/positions/positions-view.tsx | 43 +--- 9 files changed, 498 insertions(+), 263 deletions(-) create mode 100644 src/components/common/table-container-with-header.tsx create mode 100644 src/features/positions/components/collateral-icons-display.tsx rename src/features/positions/components/{positions-summary-table.tsx => supplied-morpho-blue-grouped-table.tsx} (78%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83d91caf..160fb7ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -398,7 +398,8 @@ src/modals/ /rebalance/ /preview/ /onboarding/ - positions-summary-table.tsx + supplied-morpho-blue-grouped-table.tsx + collateral-icons-display.tsx ... /autovault/ autovault-view.tsx diff --git a/docs/Styling.md b/docs/Styling.md index 8928e0d2..f1c93ee6 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -550,6 +550,76 @@ import { TablePagination } from '@/components/common/TablePagination'; **Styling:** All styling applied via `app/global.css` - don't add inline styles or override padding. +### TableContainerWithHeader Component + +**TableContainerWithHeader** (`@/components/common/table-container-with-header`) +- Standard wrapper for tables with header section +- Provides title on left, optional actions on right +- Consistent styling with border separator, overflow handling, and responsive layout + +**Structure:** +- Outer wrapper with `bg-surface`, `rounded-md`, `font-zen`, `shadow-sm` +- Header section with border bottom separator +- Title uses `font-monospace text-xs uppercase text-secondary` +- Actions area with flexbox gap spacing +- Overflow-x-auto wrapper for table content + +**Props:** +- `title`: Table heading (e.g., "Asset Activity", "Supplied Positions") +- `actions?`: Optional React node for controls (filters, refresh, settings dropdowns) +- `children`: Table component +- `className?`: Additional classes for outer wrapper + +**Usage Examples:** + +```tsx +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; +import { Table, TableHeader, TableBody } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu } from '@/components/ui/dropdown-menu'; + +// Simple table with title only + +
+ {/* table content */} +
+ + +// Table with actions (filters, refresh, settings) + + + + + + + {/* filter options */} + + + + + + } +> + + {/* table content */} +
+
+``` + +**Examples in codebase:** +- `src/features/admin/components/asset-metrics-table.tsx` +- `src/features/positions/components/supplied-morpho-blue-grouped-table.tsx` + ### TablePagination Component **TablePagination** (`@/components/common/TablePagination`) @@ -696,6 +766,53 @@ import { TransactionIdentity } from '@/components/common/TransactionIdentity'; ``` +### CollateralIconsDisplay + +Use `CollateralIconsDisplay` from `@/features/positions/components/collateral-icons-display` for displaying collateral tokens with smart overflow handling. + +**Features:** +- Shows up to `maxDisplay` collateral icons (default: 8) +- Overlapping icon style with proper z-index stacking +- Automatic sorting by amount (descending) +- "+X more" badge for additional collaterals with tooltip +- Opacity support for collaterals with zero balance + +**Props:** +- `collaterals`: Array of collateral objects with `address`, `symbol`, `amount` +- `chainId`: Network chain ID for token icons +- `maxDisplay?`: Maximum icons to display before showing "+X more" badge (default: 8) +- `iconSize?`: Size of token icons in pixels (default: 20) + +**Usage:** + +```tsx +import { CollateralIconsDisplay } from '@/features/positions/components/collateral-icons-display'; + +// Basic usage + + +// Custom display limit and icon size + +``` + +**Pattern:** +This component follows the same overlapping icon pattern as `TrustedByCell` in `trusted-vault-badges.tsx`: +- First icon has `ml-0`, subsequent icons have `-ml-2` for overlapping effect +- Z-index decreases from left to right for proper stacking +- "+X more" badge shows remaining items in tooltip +- Empty state shows "No known collaterals" message + +**Examples in codebase:** +- `src/features/positions/components/supplied-morpho-blue-grouped-table.tsx` + ## Input Components The codebase uses two different input approaches depending on the use case: diff --git a/src/components/common/table-container-with-header.tsx b/src/components/common/table-container-with-header.tsx new file mode 100644 index 00000000..1bb677e1 --- /dev/null +++ b/src/components/common/table-container-with-header.tsx @@ -0,0 +1,42 @@ +type TableContainerWithHeaderProps = { + title: string; + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; +}; + +/** + * Standard table container with header section. + * + * Provides consistent styling for tables with: + * - Title on the left (uppercase, monospace font) + * - Optional actions on the right (filters, refresh, settings, etc.) + * - Separator border between header and content + * - Responsive overflow handling + * + * @example + * + * + * ... + * + * } + * > + * ...
+ *
+ */ +export function TableContainerWithHeader({ title, actions, children, className = '' }: TableContainerWithHeaderProps) { + return ( +
+
+

{title}

+ {actions &&
{actions}
} +
+
{children}
+
+ ); +} diff --git a/src/features/admin/components/asset-metrics-table.tsx b/src/features/admin/components/asset-metrics-table.tsx index f560690b..4f31668e 100644 --- a/src/features/admin/components/asset-metrics-table.tsx +++ b/src/features/admin/components/asset-metrics-table.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { TokenIcon } from '@/components/shared/token-icon'; import { formatReadable } from '@/utils/balance'; import { calculateHumanReadableVolumes } from '@/utils/statsDataProcessing'; @@ -85,125 +86,120 @@ export function AssetMetricsTable({ data }: AssetMetricsTableProps) { }, [processedData, sortKey, sortDirection]); return ( -
-
-

Asset Activity

-
-
- {processedData.length === 0 ? ( -
No asset data available
- ) : ( - - - - Asset - - - - - - - - - {sortedData.map((asset) => { - // Use a determined chainId for display purposes - const displayChainId = asset.chainId ?? BASE_CHAIN_ID; + + {processedData.length === 0 ? ( +
No asset data available
+ ) : ( +
+ + + Asset + + + + + + + + + {sortedData.map((asset) => { + // Use a determined chainId for display purposes + const displayChainId = asset.chainId ?? BASE_CHAIN_ID; - return ( - + - -
- - {asset.assetSymbol ?? 'Unknown'} -
-
- - - {asset.totalVolumeFormatted ? `${formatReadable(Number(asset.totalVolumeFormatted))} ${asset.assetSymbol}` : '—'} - - - - {(asset.supplyCount + asset.withdrawCount).toLocaleString()} - - - {asset.supplyCount.toLocaleString()} - - - {asset.withdrawCount.toLocaleString()} - - - {asset.uniqueUsers.toLocaleString()} - -
- ); - })} -
-
- )} -
-
+
+ + {asset.assetSymbol ?? 'Unknown'} +
+ + + + {asset.totalVolumeFormatted ? `${formatReadable(Number(asset.totalVolumeFormatted))} ${asset.assetSymbol}` : '—'} + + + + {(asset.supplyCount + asset.withdrawCount).toLocaleString()} + + + {asset.supplyCount.toLocaleString()} + + + {asset.withdrawCount.toLocaleString()} + + + {asset.uniqueUsers.toLocaleString()} + + + ); + })} + + + )} + ); } diff --git a/src/features/autovault/components/vault-list.tsx b/src/features/autovault/components/vault-list.tsx index b30c547f..ea656175 100644 --- a/src/features/autovault/components/vault-list.tsx +++ b/src/features/autovault/components/vault-list.tsx @@ -51,7 +51,7 @@ export function VaultListV2({ vaults, loading }: VaultListV2Props) {

Your Vaults

-
+
diff --git a/src/features/autovault/vault-view.tsx b/src/features/autovault/vault-view.tsx index 1d7f8115..752e2591 100644 --- a/src/features/autovault/vault-view.tsx +++ b/src/features/autovault/vault-view.tsx @@ -136,7 +136,7 @@ export default function VaultContent() { return (
-
+
{/* Vault Header */}
diff --git a/src/features/positions/components/collateral-icons-display.tsx b/src/features/positions/components/collateral-icons-display.tsx new file mode 100644 index 00000000..ba8e40ba --- /dev/null +++ b/src/features/positions/components/collateral-icons-display.tsx @@ -0,0 +1,123 @@ +import { Tooltip } from '@/components/ui/tooltip'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { TooltipContent } from '@/components/shared/tooltip-content'; + +type Collateral = { + address: string; + symbol: string; + amount: number; +}; + +type MoreCollateralsBadgeProps = { + collaterals: Collateral[]; + chainId: number; + badgeSize?: number; +}; + +function MoreCollateralsBadge({ collaterals, chainId, badgeSize = 20 }: MoreCollateralsBadgeProps) { + if (collaterals.length === 0) return null; + + return ( + More collaterals} + detail={ +
+ {collaterals.map((collateral, index) => ( +
+ + {collateral.symbol} +
+ ))} +
+ } + /> + } + > + + +{collaterals.length} + +
+ ); +} + +type CollateralIconsDisplayProps = { + collaterals: Collateral[]; + chainId: number; + maxDisplay?: number; + iconSize?: number; +}; + +/** + * Display collateral icons with smart overflow handling. + * + * Shows up to `maxDisplay` collateral icons in an overlapping style, + * with a "+X more" badge for additional collaterals (with tooltip). + * + * Collaterals are automatically sorted by amount (descending). + * + * @example + * + */ +export function CollateralIconsDisplay({ collaterals, chainId, maxDisplay = 8, iconSize = 20 }: CollateralIconsDisplayProps) { + if (collaterals.length === 0) { + return No known collaterals; + } + + // Sort by amount descending + const sortedCollaterals = [...collaterals].sort((a, b) => b.amount - a.amount); + + // Split into preview and overflow + const preview = sortedCollaterals.slice(0, maxDisplay); + const remaining = sortedCollaterals.slice(maxDisplay); + + return ( +
+ {preview.map((collateral, index) => ( +
+ 0 ? 1 : 0.5} + /> +
+ ))} + {remaining.length > 0 && ( + + )} +
+ ); +} diff --git a/src/features/positions/components/positions-summary-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx similarity index 78% rename from src/features/positions/components/positions-summary-table.tsx rename to src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 39b0f644..f9c1ce1e 100644 --- a/src/features/positions/components/positions-summary-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useState, useEffect } from 'react'; import { Tooltip } from '@/components/ui/tooltip'; import { IconSwitch } from '@/components/ui/icon-switch'; -import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from '@radix-ui/react-icons'; import { GearIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; @@ -16,6 +15,7 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; @@ -38,6 +38,7 @@ import { RiskIndicator } from '@/features/markets/components/risk-indicator'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { RebalanceModal } from './rebalance/rebalance-modal'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; +import { CollateralIconsDisplay } from './collateral-icons-display'; // Component to compute and display aggregated risk indicators for a group of positions function AggregatedRiskIndicators({ groupedPosition }: { groupedPosition: GroupedPosition }) { @@ -97,7 +98,7 @@ function AggregatedRiskIndicators({ groupedPosition }: { groupedPosition: Groupe ); } -type PositionsSummaryTableProps = { +type SuppliedMorphoBlueGroupedTableProps = { account: string; marketPositions: MarketPositionWithEarnings[]; setShowWithdrawModal: (show: boolean) => void; @@ -110,7 +111,7 @@ type PositionsSummaryTableProps = { setEarningsPeriod: (period: EarningsPeriod) => void; }; -export function PositionsSummaryTable({ +export function SuppliedMorphoBlueGroupedTable({ marketPositions, setShowWithdrawModal, setShowSupplyModal, @@ -121,7 +122,7 @@ export function PositionsSummaryTable({ account, earningsPeriod, setEarningsPeriod, -}: PositionsSummaryTableProps) { +}: SuppliedMorphoBlueGroupedTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); const [selectedGroupedPosition, setSelectedGroupedPosition] = useState(null); @@ -183,88 +184,94 @@ export function PositionsSummaryTable({ ); }; - return ( -
-
- - - - - - {Object.entries(periodLabels).map(([period, label]) => ( - setEarningsPeriod(period as EarningsPeriod)} - > - {label} - - ))} - - - - } - > - - - - - - - - - + // Header actions (period dropdown, refresh, settings) + const headerActions = ( + <> + + + + + + {Object.entries(periodLabels).map(([period, label]) => ( e.preventDefault()} + key={period} + onClick={() => setEarningsPeriod(period as EarningsPeriod)} > -
- Show Collateral Exposure - -
+ {label}
-
-
-
-
+ ))} + + + + } + > + + + + + + + + + + e.preventDefault()} + > +
+ Show Collateral Exposure + +
+
+
+
+ + ); + + return ( +
+
- Network Size {rateLabel} (now) @@ -298,7 +305,7 @@ export function PositionsSummaryTable({ {processedPositions.map((groupedPosition) => { const rowKey = `${groupedPosition.loanAssetAddress}-${groupedPosition.chainId}`; - const isExpanded = expandedRows.has(rowKey); + const _isExpanded = expandedRows.has(rowKey); const avgApy = groupedPosition.totalWeightedApy; const earnings = getGroupedEarnings(groupedPosition); @@ -309,7 +316,6 @@ export function PositionsSummaryTable({ className="cursor-pointer hover:bg-gray-50" onClick={() => toggleRow(rowKey)} > - {isExpanded ? : }
-
- {groupedPosition.collaterals.length > 0 ? ( - groupedPosition.collaterals - .sort((a, b) => b.amount - a.amount) - .map((collateral, index) => ( - 0 ? 1 : 0.5} - /> - )) - ) : ( - No known collaterals - )} -
+
-
+ {showRebalanceModal && selectedGroupedPosition && ( (false); const [showWithdrawModal, setShowWithdrawModal] = useState(false); - const [showOnboardingModal, setShowOnboardingModal] = useState(false); const [selectedPosition, setSelectedPosition] = useState(null); const [earningsPeriod, setEarningsPeriod] = useState('day'); const { account } = useParams<{ account: string }>(); - const { address } = useConnection(); - - const [mounted, setMounted] = useState(false); - - const isOwner = useMemo(() => { - if (!account || !address || !mounted) return false; - return account === address; - }, [account, address, mounted]); - - useEffect(() => { - setMounted(true); - }, []); const { loading: isMarketsLoading } = useMarkets(); @@ -84,22 +68,6 @@ export default function Positions() { variant="full" showAddress /> -
- {isOwner && ( - - )} -
{showWithdrawModal && selectedPosition && ( @@ -123,11 +91,6 @@ export default function Positions() { /> )} - - {loading ? ( ) : hasSuppliedMarkets ? (
- Date: Mon, 22 Dec 2025 15:06:17 +0800 Subject: [PATCH 04/15] refactor: position settings modal --- src/components/ui/icon-switch.tsx | 12 +- .../supplied-morpho-blue-grouped-table.tsx | 152 +++++++++++------- 2 files changed, 103 insertions(+), 61 deletions(-) diff --git a/src/components/ui/icon-switch.tsx b/src/components/ui/icon-switch.tsx index f563f8cd..d9099ec6 100644 --- a/src/components/ui/icon-switch.tsx +++ b/src/components/ui/icon-switch.tsx @@ -149,11 +149,13 @@ export function IconSwitch({ const isControlled = controlledSelected !== undefined; const isSelected = isControlled ? controlledSelected : internalSelected; - // Determine which icon to use (null means no icon) + // Determine which icon to use (null/undefined means no icon) const IconComponent = thumbIconOn && thumbIconOff ? (isSelected ? thumbIconOn : thumbIconOff) : ThumbIcon; // Use compact config for plain switches, icon config otherwise - const config = IconComponent ? SIZE_CONFIG_WITH_ICON[size] : SIZE_CONFIG_PLAIN[size]; + // Treat undefined the same as null - no icon + const hasIcon = IconComponent !== null && IconComponent !== undefined; + const config = hasIcon ? SIZE_CONFIG_WITH_ICON[size] : SIZE_CONFIG_PLAIN[size]; const translate = config.width - config.thumbWidth - config.padding * 2; const handleToggle = useCallback(() => { @@ -202,7 +204,7 @@ export function IconSwitch({ onKeyDown={handleKeyDown} className={cn( 'relative inline-flex shrink-0 items-center justify-start overflow-hidden rounded-[8px] transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background', - IconComponent && 'ring-1 ring-[var(--color-background-secondary)]', + hasIcon && 'ring-1 ring-[var(--color-background-secondary)]', isSelected ? TRACK_COLOR[color] : 'bg-main', disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer', classNames?.base, @@ -215,7 +217,7 @@ export function IconSwitch({ - {IconComponent && ( + {hasIcon && IconComponent && ( - - - - - - {Object.entries(periodLabels).map(([period, label]) => ( - setEarningsPeriod(period as EarningsPeriod)} - > - {label} - - ))} - - - - - - - - e.preventDefault()} - > -
- Show Collateral Exposure - -
-
-
-
+ + } + > + + ); @@ -448,6 +415,79 @@ export function SuppliedMorphoBlueGroupedTable({ isRefetching={isRefetching} /> )} + + {(close) => ( + <> + } + onClose={close} + /> + + +
+ {Object.entries(periodLabels).map(([period, label]) => ( + + ))} +
+
+ + + + + + + + +
+ + + + + )} +
); } From 9ea01f9bc766c04b736b799ed7f81fceb3b4517a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 15:38:54 +0800 Subject: [PATCH 05/15] refactor: markets table and rewards table --- .../components/table/markets-table.tsx | 163 +++++++++++++++++- src/features/markets/markets-view.tsx | 130 +++----------- .../rewards/components/reward-table.tsx | 57 +++++- src/features/rewards/rewards-view.tsx | 7 +- 4 files changed, 233 insertions(+), 124 deletions(-) diff --git a/src/features/markets/components/table/markets-table.tsx b/src/features/markets/components/table/markets-table.tsx index 071a1313..e85084c6 100644 --- a/src/features/markets/components/table/markets-table.tsx +++ b/src/features/markets/components/table/markets-table.tsx @@ -1,7 +1,16 @@ import { useMemo, useState } from 'react'; import { FaRegStar, FaStar } from 'react-icons/fa'; +import { ReloadIcon } from '@radix-ui/react-icons'; +import { CgCompress } from 'react-icons/cg'; +import { FiSettings } from 'react-icons/fi'; +import { RiExpandHorizontalLine } from 'react-icons/ri'; import { Table, TableHeader, TableRow, TableHead } from '@/components/ui/table'; import { TablePagination } from '@/components/shared/table-pagination'; +import { Button } from '@/components/ui/button'; +import { Tooltip } from '@/components/ui/tooltip'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; +import { SuppliedAssetFilterCompactSwitch } from '@/features/positions/components/supplied-asset-filter-compact-switch'; import type { TrustedVault } from '@/constants/vaults/known_vaults'; import { useRateLabel } from '@/hooks/useRateLabel'; import type { Market } from '@/utils/types'; @@ -31,6 +40,32 @@ type MarketsTableProps = { tableClassName?: string; addBlacklistedMarket?: (uniqueKey: string, chainId: number, reason?: string) => boolean; isBlacklisted?: (uniqueKey: string) => boolean; + // Settings props + includeUnknownTokens: boolean; + setIncludeUnknownTokens: (value: boolean) => void; + showUnknownOracle: boolean; + setShowUnknownOracle: (value: boolean) => void; + showUnwhitelistedMarkets: boolean; + setShowUnwhitelistedMarkets: (value: boolean) => void; + trustedVaultsOnly: boolean; + setTrustedVaultsOnly: (value: boolean) => void; + minSupplyEnabled: boolean; + setMinSupplyEnabled: (value: boolean) => void; + minBorrowEnabled: boolean; + setMinBorrowEnabled: (value: boolean) => void; + minLiquidityEnabled: boolean; + setMinLiquidityEnabled: (value: boolean) => void; + thresholds: { + minSupply: number; + minBorrow: number; + minLiquidity: number; + }; + onOpenSettings: () => void; + onRefresh: () => void; + isRefetching: boolean; + tableViewMode: 'compact' | 'expanded'; + setTableViewMode: (mode: 'compact' | 'expanded') => void; + isMobile: boolean; }; function MarketsTable({ @@ -53,6 +88,27 @@ function MarketsTable({ tableClassName, addBlacklistedMarket, isBlacklisted, + includeUnknownTokens, + setIncludeUnknownTokens, + showUnknownOracle, + setShowUnknownOracle, + showUnwhitelistedMarkets, + setShowUnwhitelistedMarkets, + trustedVaultsOnly, + setTrustedVaultsOnly, + minSupplyEnabled, + setMinSupplyEnabled, + minBorrowEnabled, + setMinBorrowEnabled, + minLiquidityEnabled, + setMinLiquidityEnabled, + thresholds, + onOpenSettings, + onRefresh, + isRefetching, + tableViewMode, + setTableViewMode, + isMobile, }: MarketsTableProps) { const [expandedRowId, setExpandedRowId] = useState(null); const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' }); @@ -66,15 +122,112 @@ function MarketsTable({ const totalPages = Math.ceil(markets.length / entriesPerPage); + const effectiveTableViewMode = isMobile ? 'compact' : tableViewMode; + const containerClassName = ['flex flex-col gap-2 pb-4', className].filter((value): value is string => Boolean(value)).join(' '); - const tableWrapperClassName = ['bg-surface shadow-sm rounded overflow-hidden', wrapperClassName] - .filter((value): value is string => Boolean(value)) - .join(' '); const tableClassNames = ['responsive', tableClassName].filter((value): value is string => Boolean(value)).join(' '); + // Header actions (filter, refresh, expand/compact, settings) + const headerActions = ( + <> + + + + } + > + + + + + + {/* Hide expand/compact toggle on mobile */} + {!isMobile && ( + : } + title={effectiveTableViewMode === 'compact' ? 'Expand Table' : 'Compact Table'} + detail={ + effectiveTableViewMode === 'compact' + ? 'Expand table to full width, useful when more columns are enabled.' + : 'Restore compact table view' + } + /> + } + > + + + + + )} + + + } + > + + + + + + ); + return (
-
+ @@ -219,7 +372,7 @@ function MarketsTable({ isBlacklisted={isBlacklisted} />
-
+
- - {/* Settings buttons */} -
- - - - } - > - - - - - - {/* Hide expand/compact toggle on mobile */} -
- : } - title={effectiveTableViewMode === 'compact' ? 'Expand Table' : 'Compact Table'} - detail={ - effectiveTableViewMode === 'compact' - ? 'Expand table to full width, useful when more columns are enabled.' - : 'Restore compact table view' - } - /> - } - > - - - - -
- - - } - > - - - - -
@@ -656,6 +551,31 @@ export default function Markets({ initialNetwork, initialCollaterals, initialLoa tableClassName={effectiveTableViewMode === 'compact' ? 'w-full min-w-full' : undefined} addBlacklistedMarket={addBlacklistedMarket} isBlacklisted={isBlacklisted} + includeUnknownTokens={includeUnknownTokens} + setIncludeUnknownTokens={setIncludeUnknownTokens} + showUnknownOracle={showUnknownOracle} + setShowUnknownOracle={setShowUnknownOracle} + showUnwhitelistedMarkets={showUnwhitelistedMarkets} + setShowUnwhitelistedMarkets={setShowUnwhitelistedMarkets} + trustedVaultsOnly={trustedVaultsOnly} + setTrustedVaultsOnly={setTrustedVaultsOnly} + minSupplyEnabled={minSupplyEnabled} + setMinSupplyEnabled={setMinSupplyEnabled} + minBorrowEnabled={minBorrowEnabled} + setMinBorrowEnabled={setMinBorrowEnabled} + minLiquidityEnabled={minLiquidityEnabled} + setMinLiquidityEnabled={setMinLiquidityEnabled} + thresholds={{ + minSupply: effectiveMinSupply, + minBorrow: effectiveMinBorrow, + minLiquidity: effectiveMinLiquidity, + }} + onOpenSettings={onSettingsModalOpen} + onRefresh={handleRefresh} + isRefetching={isRefetching} + tableViewMode={tableViewMode} + setTableViewMode={setTableViewMode} + isMobile={isMobile} /> ) : ( void; + isRefetching: boolean; }; -export default function RewardTable({ rewards, distributions, merklRewardsWithProofs, account }: RewardTableProps) { +export default function RewardTable({ + rewards, + distributions, + merklRewardsWithProofs, + account, + onRefresh, + isRefetching, +}: RewardTableProps) { const { chainId } = useConnection(); const currentChainId = useChainId(); const toast = useStyledToast(); @@ -135,17 +147,44 @@ export default function RewardTable({ rewards, distributions, merklRewardsWithPr [account, merklRewardsWithProofs, claimSingleReward, toast], ); + // Header actions (refresh) + const headerActions = ( + + } + > + + + + + ); + return (
-
+ - ASSET - CHAIN - CLAIMABLE - CAMPAIGN - ACTIONS + Asset + Chain + Claimable + Campaign + Actions @@ -277,7 +316,7 @@ export default function RewardTable({ rewards, distributions, merklRewardsWithPr })}
-
+
); } diff --git a/src/features/rewards/rewards-view.tsx b/src/features/rewards/rewards-view.tsx index 095e04ae..4a943bfe 100644 --- a/src/features/rewards/rewards-view.tsx +++ b/src/features/rewards/rewards-view.tsx @@ -249,11 +249,6 @@ export default function Rewards() {
-
-
-

All Rewards

-
-
{loadingRewards ? ( ) : rewards.length === 0 ? ( @@ -264,6 +259,8 @@ export default function Rewards() { rewards={allRewards} distributions={distributions} merklRewardsWithProofs={merklRewardsWithProofs} + onRefresh={refresh} + isRefetching={loadingRewards} /> )}
From 2dfaca3dd40310e6da1a870e59ac40841de036ce Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 15:52:36 +0800 Subject: [PATCH 06/15] misc: remove token symbol matching --- .../history/components/history-table.tsx | 31 ++----------------- src/features/history/history-view.tsx | 4 --- .../components/position-actions-dropdown.tsx | 12 ++----- .../supplied-morpho-blue-grouped-table.tsx | 1 - src/hooks/useUserRebalancerInfo.ts | 5 +++ 5 files changed, 9 insertions(+), 44 deletions(-) diff --git a/src/features/history/components/history-table.tsx b/src/features/history/components/history-table.tsx index 59cebc4e..3233afa3 100644 --- a/src/features/history/components/history-table.tsx +++ b/src/features/history/components/history-table.tsx @@ -5,7 +5,6 @@ import { useSearchParams } from 'next/navigation'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from '@/components/ui/table'; import { ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; -import { RiRobot2Line } from 'react-icons/ri'; import { formatUnits } from 'viem'; import { TablePagination } from '@/components/shared/table-pagination'; import { TransactionIdentity } from '@/components/shared/transaction-identity'; @@ -17,12 +16,11 @@ import { useMarkets } from '@/contexts/MarketsContext'; import useUserTransactions from '@/hooks/useUserTransactions'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg, getNetworkName } from '@/utils/networks'; -import { UserTxTypes, type UserRebalancerInfo, type Market, type MarketPosition, type UserTransaction } from '@/utils/types'; +import { UserTxTypes, type Market, type MarketPosition, type UserTransaction } from '@/utils/types'; type HistoryTableProps = { account: string | undefined; positions: MarketPosition[]; - rebalancerInfos: UserRebalancerInfo[]; }; type AssetKey = { @@ -55,7 +53,7 @@ const formatTimeAgo = (timestamp: number): string => { return `${diffInYears}y ago`; }; -export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTableProps) { +export function HistoryTable({ account, positions }: HistoryTableProps) { const searchParams = useSearchParams(); const [selectedAsset, setSelectedAsset] = useState(null); const [isOpen, setIsOpen] = useState(false); @@ -98,7 +96,6 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab const chainIdParam = searchParams.get('chainId'); const tokenAddressParam = searchParams.get('tokenAddress'); - const tokenSymbolParam = searchParams.get('tokenSymbol'); // If no URL params, we're done initializing if (!chainIdParam || !tokenAddressParam) { @@ -119,19 +116,6 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab if (matchingAsset) { setSelectedAsset(matchingAsset); setHasInitializedFromUrl(true); - } else if (tokenSymbolParam) { - // If not in positions, create from URL params (user might not have position but coming from link) - const decimals = - allMarkets.find((m) => m.morphoBlue.chain.id === chainId && m.loanAsset.address.toLowerCase() === tokenAddressParam.toLowerCase()) - ?.loanAsset.decimals ?? 18; - - setSelectedAsset({ - symbol: tokenSymbolParam, - chainId, - address: tokenAddressParam, - decimals, - }); - setHasInitializedFromUrl(true); } }, [searchParams, uniqueAssets, allMarkets, hasInitializedFromUrl]); @@ -390,11 +374,6 @@ export function HistoryTable({ account, positions, rebalancerInfos }: HistoryTab const sign = tx.type === UserTxTypes.MarketSupply ? '+' : '-'; const side = tx.type === UserTxTypes.MarketSupply ? 'Supply' : 'Withdraw'; - // Find the rebalancer info for the specific network of the transaction - const networkRebalancerInfo = rebalancerInfos.find((info) => info.network === market.morphoBlue.chain.id); - // Check if the transaction hash exists in the transactions of the found rebalancer info - const isAgent = networkRebalancerInfo?.transactions.some((agentTx) => agentTx.transactionHash === tx.hash); - return ( {side} - {isAgent && ( - - )} diff --git a/src/features/history/history-view.tsx b/src/features/history/history-view.tsx index 55c8e1a5..a5a0b1e4 100644 --- a/src/features/history/history-view.tsx +++ b/src/features/history/history-view.tsx @@ -2,14 +2,11 @@ import Header from '@/components/layout/header/Header'; import useUserPositions from '@/hooks/useUserPositions'; -import { useUserRebalancerInfo } from '@/hooks/useUserRebalancerInfo'; import { HistoryTable } from './components/history-table'; export default function HistoryContent({ account }: { account: string }) { const { data: positions } = useUserPositions(account, true); - const { rebalancerInfos } = useUserRebalancerInfo(account); - return (
@@ -20,7 +17,6 @@ export default function HistoryContent({ account }: { account: string }) {
diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 89323c8d..d29e17e1 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -12,19 +12,11 @@ type PositionActionsDropdownProps = { account: string; chainId: number; tokenAddress: string; - tokenSymbol: string; isOwner: boolean; onRebalanceClick: () => void; }; -export function PositionActionsDropdown({ - account, - chainId, - tokenAddress, - tokenSymbol, - isOwner, - onRebalanceClick, -}: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ account, chainId, tokenAddress, isOwner, onRebalanceClick }: PositionActionsDropdownProps) { const router = useRouter(); const handleClick = (e: React.MouseEvent) => { @@ -37,7 +29,7 @@ export function PositionActionsDropdown({ }; const handleHistoryClick = () => { - const historyUrl = `/history/${account}?chainId=${chainId}&tokenAddress=${tokenAddress}&tokenSymbol=${tokenSymbol}`; + const historyUrl = `/history/${account}?chainId=${chainId}&tokenAddress=${tokenAddress}`; router.push(historyUrl); }; diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index e8f27edd..d4fd3e9a 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -360,7 +360,6 @@ export function SuppliedMorphoBlueGroupedTable({ account={account} chainId={groupedPosition.chainId} tokenAddress={groupedPosition.loanAssetAddress} - tokenSymbol={groupedPosition.loanAsset} isOwner={isOwner} onRebalanceClick={() => { if (!isOwner) { diff --git a/src/hooks/useUserRebalancerInfo.ts b/src/hooks/useUserRebalancerInfo.ts index c000b98b..525202d7 100644 --- a/src/hooks/useUserRebalancerInfo.ts +++ b/src/hooks/useUserRebalancerInfo.ts @@ -4,6 +4,11 @@ import { networks, isAgentAvailable } from '@/utils/networks'; import type { UserRebalancerInfo } from '@/utils/types'; import { getMonarchAgentUrl } from '@/utils/urls'; +/** + * Get monarch v1 rebalancer info + * @param account + * @returns + */ export function useUserRebalancerInfo(account: string | undefined) { const [loading, setLoading] = useState(true); const [data, setData] = useState([]); From b831c2e32c0a3e9b052e8510cada2349b7da59d4 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 16:52:42 +0800 Subject: [PATCH 07/15] feat: get portfolio value --- src/data-sources/morpho-api/prices.ts | 88 ++++++++ .../components/portfolio-value-badge.tsx | 27 +++ src/features/positions/positions-view.tsx | 18 +- src/graphql/morpho-api-queries.ts | 17 ++ src/hooks/usePortfolioValue.ts | 54 +++++ src/hooks/useTokenPrices.ts | 65 ++++++ src/utils/portfolio.ts | 190 ++++++++++++++++++ 7 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/data-sources/morpho-api/prices.ts create mode 100644 src/features/positions/components/portfolio-value-badge.tsx create mode 100644 src/hooks/usePortfolioValue.ts create mode 100644 src/hooks/useTokenPrices.ts create mode 100644 src/utils/portfolio.ts diff --git a/src/data-sources/morpho-api/prices.ts b/src/data-sources/morpho-api/prices.ts new file mode 100644 index 00000000..893bdd36 --- /dev/null +++ b/src/data-sources/morpho-api/prices.ts @@ -0,0 +1,88 @@ +import { assetPricesQuery } from '@/graphql/morpho-api-queries'; +import { morphoGraphqlFetcher } from './fetchers'; + +// Type for token price input +export type TokenPriceInput = { + address: string; + chainId: number; +}; + +// Type for asset price response from Morpho API +type AssetPriceItem = { + address: string; + symbol: string; + decimals: number; + chain: { + id: number; + }; + priceUsd: number | null; +}; + +type AssetPricesResponse = { + data: { + assets: { + items: AssetPriceItem[]; + }; + }; +}; + +// Create a unique key for token prices +export const getTokenPriceKey = (address: string, chainId: number): string => { + return `${address.toLowerCase()}-${chainId}`; +}; + +/** + * Fetches token prices from Morpho API for a list of tokens + * @param tokens - Array of token addresses and chain IDs + * @returns Map of token prices keyed by address-chainId + */ +export const fetchTokenPrices = async (tokens: TokenPriceInput[]): Promise> => { + if (tokens.length === 0) { + return new Map(); + } + + // Group tokens by chain for efficient querying + const tokensByChain = new Map(); + tokens.forEach((token) => { + const existing = tokensByChain.get(token.chainId) ?? []; + // Deduplicate and lowercase addresses + const normalizedAddress = token.address.toLowerCase(); + if (!existing.includes(normalizedAddress)) { + existing.push(normalizedAddress); + } + tokensByChain.set(token.chainId, existing); + }); + + // Fetch prices for all chains in parallel + const priceMap = new Map(); + + await Promise.all( + Array.from(tokensByChain.entries()).map(async ([chainId, addresses]) => { + try { + const response = await morphoGraphqlFetcher(assetPricesQuery, { + where: { + address_in: addresses, + chainId_in: [chainId], + }, + }); + + if (!response.data?.assets?.items) { + console.warn(`No price data returned for chain ${chainId}`); + return; + } + + // Process each asset and add to price map + response.data.assets.items.forEach((asset) => { + if (asset.priceUsd !== null) { + const key = getTokenPriceKey(asset.address, asset.chain.id); + priceMap.set(key, asset.priceUsd); + } + }); + } catch (error) { + console.error(`Failed to fetch prices for chain ${chainId}:`, error); + } + }), + ); + + return priceMap; +}; diff --git a/src/features/positions/components/portfolio-value-badge.tsx b/src/features/positions/components/portfolio-value-badge.tsx new file mode 100644 index 00000000..5f0c037a --- /dev/null +++ b/src/features/positions/components/portfolio-value-badge.tsx @@ -0,0 +1,27 @@ +import { formatUsdValue } from '@/utils/portfolio'; + +type PortfolioValueBadgeProps = { + totalUsd: number; + isLoading: boolean; + error: Error | null; + onClick?: () => void; +}; + +export function PortfolioValueBadge({ totalUsd, isLoading, error, onClick }: PortfolioValueBadgeProps) { + return ( + + ); +} diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 93dae4e5..726e964f 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -15,8 +15,10 @@ import { SupplyModalV2 } from '@/modals/supply/supply-modal'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { useMarkets } from '@/hooks/useMarkets'; import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; +import { usePortfolioValue } from '@/hooks/usePortfolioValue'; import type { MarketPosition } from '@/utils/types'; import { SuppliedMorphoBlueGroupedTable } from './components/supplied-morpho-blue-grouped-table'; +import { PortfolioValueBadge } from './components/portfolio-value-badge'; export default function Positions() { const [showSupplyModal, setShowSupplyModal] = useState(false); @@ -37,6 +39,9 @@ export default function Positions() { loadingStates, } = useUserPositionsSummaryData(account, earningsPeriod); + // Calculate portfolio value from positions + const { totalUsd, isLoading: isPricesLoading, error: pricesError } = usePortfolioValue(marketPositions); + const loading = isMarketsLoading || isPositionsLoading; // Generate loading message based on current state @@ -62,12 +67,23 @@ export default function Positions() {

Portfolio

-
+
+ {!loading && hasSuppliedMarkets && ( + { + // TODO: Add click handler (show breakdown modal, navigate, etc.) + console.log('Portfolio value clicked'); + }} + /> + )}
{showWithdrawModal && selectedPosition && ( diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts index b0b33444..55ee6291 100644 --- a/src/graphql/morpho-api-queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -595,3 +595,20 @@ export const vaultV2Query = ` } } `; + +// Query for fetching token prices from Morpho API +export const assetPricesQuery = ` + query getAssetPrices($where: AssetsFilters) { + assets(where: $where) { + items { + address + symbol + decimals + chain { + id + } + priceUsd + } + } + } +`; diff --git a/src/hooks/usePortfolioValue.ts b/src/hooks/usePortfolioValue.ts new file mode 100644 index 00000000..85079482 --- /dev/null +++ b/src/hooks/usePortfolioValue.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { MarketPositionWithEarnings } from '@/utils/types'; +import { calculatePortfolioValue, extractTokensFromPositions, extractTokensFromVaults } from '@/utils/portfolio'; +import { useTokenPrices } from './useTokenPrices'; + +type UsePortfolioValueReturn = { + totalUsd: number; + nativeSuppliesUsd: number; + vaultsUsd: number; + isLoading: boolean; + error: Error | null; +}; + +/** + * Hook to calculate total portfolio value from positions and vaults + * @param positions - Array of market positions with earnings + * @param vaults - Optional array of user vaults + * @returns Portfolio value breakdown and loading/error states + */ +export const usePortfolioValue = (positions: MarketPositionWithEarnings[], vaults?: UserVaultV2[]): UsePortfolioValueReturn => { + // Extract unique tokens from all sources + const tokens = useMemo(() => { + const positionTokens = extractTokensFromPositions(positions); + const vaultTokens = vaults ? extractTokensFromVaults(vaults) : []; + return [...positionTokens, ...vaultTokens]; + }, [positions, vaults]); + + // Fetch prices for all tokens + const { prices, isLoading, error } = useTokenPrices(tokens); + + // Calculate portfolio value (memoized) + const portfolioValue = useMemo(() => { + if (isLoading || prices.size === 0) { + return { + total: 0, + breakdown: { + nativeSupplies: 0, + vaults: 0, + }, + }; + } + + return calculatePortfolioValue(positions, vaults, prices); + }, [positions, vaults, prices, isLoading]); + + return { + totalUsd: portfolioValue.total, + nativeSuppliesUsd: portfolioValue.breakdown.nativeSupplies, + vaultsUsd: portfolioValue.breakdown.vaults, + isLoading, + error, + }; +}; diff --git a/src/hooks/useTokenPrices.ts b/src/hooks/useTokenPrices.ts new file mode 100644 index 00000000..7c5d8f93 --- /dev/null +++ b/src/hooks/useTokenPrices.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchTokenPrices, type TokenPriceInput } from '@/data-sources/morpho-api/prices'; + +// Query keys for token prices +export const tokenPriceKeys = { + all: ['tokenPrices'] as const, + tokens: (tokens: TokenPriceInput[]) => { + // Create a stable, sorted key from tokens + const sortedTokens = [...tokens] + .map((t) => `${t.address.toLowerCase()}-${t.chainId}`) + .sort() + .join(','); + return [...tokenPriceKeys.all, sortedTokens] as const; + }, +}; + +type UseTokenPricesReturn = { + prices: Map; + isLoading: boolean; + error: Error | null; +}; + +/** + * Hook to fetch and cache token prices from Morpho API + * @param tokens - Array of token addresses and chain IDs to fetch prices for + * @returns Object containing prices map, loading state, and error + */ +export const useTokenPrices = (tokens: TokenPriceInput[]): UseTokenPricesReturn => { + // Memoize the token list to prevent unnecessary refetches + const stableTokens = useMemo(() => { + // Deduplicate tokens based on address-chainId combination + const uniqueTokens = new Map(); + tokens.forEach((token) => { + const key = `${token.address.toLowerCase()}-${token.chainId}`; + if (!uniqueTokens.has(key)) { + uniqueTokens.set(key, { + address: token.address.toLowerCase(), + chainId: token.chainId, + }); + } + }); + return Array.from(uniqueTokens.values()); + }, [tokens]); + + const { + data: prices, + isLoading, + error, + } = useQuery, Error>({ + queryKey: tokenPriceKeys.tokens(stableTokens), + queryFn: async () => { + return fetchTokenPrices(stableTokens); + }, + enabled: stableTokens.length > 0, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }); + + return { + prices: prices ?? new Map(), + isLoading, + error: error ?? null, + }; +}; diff --git a/src/utils/portfolio.ts b/src/utils/portfolio.ts new file mode 100644 index 00000000..2f7a8a26 --- /dev/null +++ b/src/utils/portfolio.ts @@ -0,0 +1,190 @@ +import { formatUnits } from 'viem'; +import { getTokenPriceKey, type TokenPriceInput } from '@/data-sources/morpho-api/prices'; +import type { MarketPositionWithEarnings } from './types'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; + +// Normalized balance type for all position sources +export type TokenBalance = { + tokenAddress: string; + chainId: number; + balance: bigint; + decimals: number; +}; + +// Portfolio breakdown by source +export type PortfolioBreakdown = { + nativeSupplies: number; + vaults: number; +}; + +// Portfolio value result +export type PortfolioValue = { + total: number; + breakdown: PortfolioBreakdown; +}; + +/** + * Extract unique tokens from native Morpho Blue positions + * @param positions - Array of market positions with earnings + * @returns Array of token price inputs for fetching + */ +export const extractTokensFromPositions = (positions: MarketPositionWithEarnings[]): TokenPriceInput[] => { + const uniqueTokens = new Set(); + const tokens: TokenPriceInput[] = []; + + positions.forEach((position) => { + const tokenAddress = position.market.loanAsset.address; + const chainId = position.market.morphoBlue.chain.id; + const key = getTokenPriceKey(tokenAddress, chainId); + + if (!uniqueTokens.has(key)) { + uniqueTokens.add(key); + tokens.push({ address: tokenAddress, chainId }); + } + }); + + return tokens; +}; + +/** + * Extract unique tokens from vault positions + * @param vaults - Array of user vaults + * @returns Array of token price inputs for fetching + */ +export const extractTokensFromVaults = (vaults: UserVaultV2[]): TokenPriceInput[] => { + const uniqueTokens = new Set(); + const tokens: TokenPriceInput[] = []; + + vaults.forEach((vault) => { + const tokenAddress = vault.asset; + const chainId = vault.networkId; + const key = getTokenPriceKey(tokenAddress, chainId); + + if (!uniqueTokens.has(key)) { + uniqueTokens.add(key); + tokens.push({ address: tokenAddress, chainId }); + } + }); + + return tokens; +}; + +/** + * Calculate total USD value from token balances and prices + * @param balances - Array of token balances + * @param prices - Map of token prices keyed by address-chainId + * @returns Total USD value + */ +export const calculateUsdValue = (balances: TokenBalance[], prices: Map): number => { + let totalUsd = 0; + + balances.forEach((balance) => { + const priceKey = getTokenPriceKey(balance.tokenAddress, balance.chainId); + const price = prices.get(priceKey); + + if (price !== undefined && balance.balance > 0n) { + // Convert balance to decimal using token decimals + const balanceDecimal = Number.parseFloat(formatUnits(balance.balance, balance.decimals)); + // Calculate USD value + const usdValue = balanceDecimal * price; + totalUsd += usdValue; + } + }); + + return totalUsd; +}; + +/** + * Convert native positions to token balances + * @param positions - Array of market positions + * @returns Array of token balances + */ +export const positionsToBalances = (positions: MarketPositionWithEarnings[]): TokenBalance[] => { + return positions.map((position) => ({ + tokenAddress: position.market.loanAsset.address, + chainId: position.market.morphoBlue.chain.id, + balance: BigInt(position.state.supplyAssets), + decimals: position.market.loanAsset.decimals, + })); +}; + +/** + * Convert vaults to token balances + * @param vaults - Array of user vaults + * @returns Array of token balances + */ +export const vaultsToBalances = (vaults: UserVaultV2[]): TokenBalance[] => { + return vaults + .filter((vault) => vault.balance !== undefined) + .map((vault) => { + // Find the asset decimals from the vault data + // Default to 18 if not available (most ERC20 tokens use 18 decimals) + const decimals = 18; + + return { + tokenAddress: vault.asset, + chainId: vault.networkId, + balance: vault.balance ?? 0n, + decimals, + }; + }); +}; + +/** + * Calculate portfolio value from positions and vaults + * @param positions - Array of market positions + * @param vaults - Array of user vaults (optional) + * @param prices - Map of token prices + * @returns Portfolio value with breakdown + */ +export const calculatePortfolioValue = ( + positions: MarketPositionWithEarnings[], + vaults: UserVaultV2[] | undefined, + prices: Map, +): PortfolioValue => { + // Convert positions to balances + const positionBalances = positionsToBalances(positions); + const nativeSuppliesUsd = calculateUsdValue(positionBalances, prices); + + // Convert vaults to balances (if provided) + let vaultsUsd = 0; + if (vaults && vaults.length > 0) { + const vaultBalances = vaultsToBalances(vaults); + vaultsUsd = calculateUsdValue(vaultBalances, prices); + } + + return { + total: nativeSuppliesUsd + vaultsUsd, + breakdown: { + nativeSupplies: nativeSuppliesUsd, + vaults: vaultsUsd, + }, + }; +}; + +/** + * Format USD value for display + * @param value - USD value to format + * @param decimals - Number of decimal places (default: 2) + * @returns Formatted USD string + */ +export const formatUsdValue = (value: number, decimals = 2): string => { + if (value === 0) return '$0.00'; + + // Use compact notation for large values + if (value >= 1_000_000) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 2, + }).format(value); + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +}; From b54554d64c77d66f98e332235438394f670977dc Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 18:19:20 +0800 Subject: [PATCH 08/15] feat: add autovault table to the portfolio page --- .../common/table-container-with-header.tsx | 2 +- .../autovault/components/vault-list.tsx | 154 ----------- src/features/autovault/vault-list-view.tsx | 32 ++- .../components/simplified-risk-indicator.tsx | 61 +++++ .../components/supplied-amount-cell.tsx | 18 ++ .../components/supplied-markets-detail.tsx | 17 +- .../supplied-morpho-blue-grouped-table.tsx | 8 +- .../components/supplied-percentage-cell.tsx | 23 ++ .../components/user-vaults-table.tsx | 239 ++++++++++++++++++ .../components/vault-actions-dropdown.tsx | 73 ++++++ .../components/vault-allocation-detail.tsx | 217 ++++++++++++++++ .../components/vault-risk-indicators.tsx | 85 +++++++ src/features/positions/positions-view.tsx | 112 +++++--- src/hooks/useUserVaultsV2.ts | 13 +- 14 files changed, 833 insertions(+), 221 deletions(-) delete mode 100644 src/features/autovault/components/vault-list.tsx create mode 100644 src/features/positions/components/simplified-risk-indicator.tsx create mode 100644 src/features/positions/components/supplied-amount-cell.tsx create mode 100644 src/features/positions/components/supplied-percentage-cell.tsx create mode 100644 src/features/positions/components/user-vaults-table.tsx create mode 100644 src/features/positions/components/vault-actions-dropdown.tsx create mode 100644 src/features/positions/components/vault-allocation-detail.tsx create mode 100644 src/features/positions/components/vault-risk-indicators.tsx diff --git a/src/components/common/table-container-with-header.tsx b/src/components/common/table-container-with-header.tsx index 1bb677e1..b673f92d 100644 --- a/src/components/common/table-container-with-header.tsx +++ b/src/components/common/table-container-with-header.tsx @@ -32,7 +32,7 @@ type TableContainerWithHeaderProps = { export function TableContainerWithHeader({ title, actions, children, className = '' }: TableContainerWithHeaderProps) { return (
-
+

{title}

{actions &&
{actions}
}
diff --git a/src/features/autovault/components/vault-list.tsx b/src/features/autovault/components/vault-list.tsx deleted file mode 100644 index ea656175..00000000 --- a/src/features/autovault/components/vault-list.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import Image from 'next/image'; -import Link from 'next/link'; -import { formatUnits } from 'viem'; -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { Spinner } from '@/components/ui/spinner'; -import { useTokens } from '@/components/providers/TokenProvider'; -import { TokenIcon } from '@/components/shared/token-icon'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; -import { useMarkets } from '@/hooks/useMarkets'; -import { useRateLabel } from '@/hooks/useRateLabel'; -import { formatReadable } from '@/utils/balance'; -import { parseCapIdParams } from '@/utils/morpho'; -import { SupportedNetworks, getNetworkImg } from '@/utils/networks'; -import { convertApyToApr } from '@/utils/rateMath'; - -type VaultListV2Props = { - vaults: UserVaultV2[]; - loading: boolean; -}; - -export function VaultListV2({ vaults, loading }: VaultListV2Props) { - const { findToken } = useTokens(); - const { isAprDisplay } = useMarkets(); - const { short: rateLabel } = useRateLabel(); - - if (loading) { - return ( -
-
- -

Loading your vaults...

-
-
- ); - } - - if (vaults.length === 0) { - return ( -
-
- 🏛️ -
-

No Vaults Found

-

You haven't deployed any autovaults yet. Create your first one to get started!

-
- ); - } - - return ( -
-

Your Vaults

- -
- - - - ID - Asset - {rateLabel} - Collaterals - Action - - - - {vaults.map((vault) => { - const token = findToken(vault.asset, vault.networkId); - const networkImg = getNetworkImg(vault.networkId); - - const collaterals = vault.caps - .map((cap) => parseCapIdParams(cap.idParams).collateralToken) - .filter((collat) => collat !== undefined); - - return ( - - {/* ID */} - -
- {networkImg && ( - - )} - {vault.address.slice(2, 8)} -
-
- - {/* Asset */} - -
- - {vault.balance && token ? formatReadable(formatUnits(BigInt(vault.balance), token.decimals)) : '0'} - - {token?.symbol ?? 'USDC'} - -
-
- - {/* APY/APR */} - - - {vault.avgApy != null ? `${((isAprDisplay ? convertApyToApr(vault.avgApy) : vault.avgApy) * 100).toFixed(2)}%` : '—'} - - - - {/* Collaterals */} - - - {collaterals.map((tokenAddress) => ( -
- -
- ))} -
-
- - {/* Action */} - -
- - - -
-
-
- ); - })} -
-
-
-
- ); -} diff --git a/src/features/autovault/vault-list-view.tsx b/src/features/autovault/vault-list-view.tsx index 123b5998..63a6f336 100644 --- a/src/features/autovault/vault-list-view.tsx +++ b/src/features/autovault/vault-list-view.tsx @@ -4,14 +4,15 @@ import { useState } from 'react'; import { FaPlus } from 'react-icons/fa'; import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import AccountConnect from '@/components/layout/header/AccountConnect'; import Header from '@/components/layout/header/Header'; import { useUserVaultsV2 } from '@/hooks/useUserVaultsV2'; +import { UserVaultsTable } from '@/features/positions/components/user-vaults-table'; import { DeploymentModal } from './components/deployment/deployment-modal'; -import { VaultListV2 } from './components/vault-list'; export default function AutovaultListContent() { - const { isConnected } = useConnection(); + const { isConnected, address } = useConnection(); const [showDeploymentModal, setShowDeploymentModal] = useState(false); const { vaults, loading: vaultsLoading } = useUserVaultsV2(); @@ -75,10 +76,29 @@ export default function AutovaultListContent() {
- + {vaultsLoading ? ( +
+
+ +

Loading your vaults...

+
+
+ ) : vaults.length === 0 ? ( +
+
+ 🏛️ +
+

No Vaults Found

+

+ You haven't deployed any autovaults yet. Create your first one to get started! +

+
+ ) : ( + + )}
{/* Deployment Modal */} diff --git a/src/features/positions/components/simplified-risk-indicator.tsx b/src/features/positions/components/simplified-risk-indicator.tsx new file mode 100644 index 00000000..3545e868 --- /dev/null +++ b/src/features/positions/components/simplified-risk-indicator.tsx @@ -0,0 +1,61 @@ +import { Tooltip } from '@/components/ui/tooltip'; +import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; +import type { Market } from '@/utils/types'; + +type RiskLevel = 'Low' | 'Medium' | 'High'; + +type SimplifiedRiskIndicatorProps = { + market: Market; +}; + +const computeRiskLevel = (market: Market): { level: RiskLevel; description: string } => { + const warnings = computeMarketWarnings(market, true); + + const hasAlert = warnings.some((w) => w.level === 'alert'); + const hasWarning = warnings.some((w) => w.level === 'warning'); + + if (hasAlert) { + const alertWarnings = warnings.filter((w) => w.level === 'alert'); + return { + level: 'High', + description: alertWarnings.map((w) => w.description).join(', '), + }; + } + + if (hasWarning) { + const warningMessages = warnings.filter((w) => w.level === 'warning'); + return { + level: 'Medium', + description: warningMessages.map((w) => w.description).join(', '), + }; + } + + return { + level: 'Low', + description: 'No significant risks detected', + }; +}; + +const getRiskStyles = (level: RiskLevel): { bg: string; text: string } => { + switch (level) { + case 'High': + return { bg: 'bg-red-100 dark:bg-red-900/20', text: 'text-red-700 dark:text-red-400' }; + case 'Medium': + return { bg: 'bg-yellow-100 dark:bg-yellow-900/20', text: 'text-yellow-700 dark:text-yellow-400' }; + case 'Low': + return { bg: 'bg-green-100 dark:bg-green-900/20', text: 'text-green-700 dark:text-green-400' }; + default: + return { bg: 'bg-green-100 dark:bg-green-900/20', text: 'text-green-700 dark:text-green-400' }; + } +}; + +export function SimplifiedRiskIndicator({ market }: SimplifiedRiskIndicatorProps) { + const { level, description } = computeRiskLevel(market); + const { bg, text } = getRiskStyles(level); + + return ( + {description}
}> + {level} + + ); +} diff --git a/src/features/positions/components/supplied-amount-cell.tsx b/src/features/positions/components/supplied-amount-cell.tsx new file mode 100644 index 00000000..705ef9fc --- /dev/null +++ b/src/features/positions/components/supplied-amount-cell.tsx @@ -0,0 +1,18 @@ +import { formatReadable } from '@/utils/balance'; + +type SuppliedAmountCellProps = { + amount: number; + symbol: string; +}; + +/** + * Shared component for displaying supplied amount in expanded tables. + * Used by both Morpho Blue and Vault allocation details. + */ +export function SuppliedAmountCell({ amount, symbol }: SuppliedAmountCellProps) { + return ( + <> + {formatReadable(amount)} {symbol} + + ); +} diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx index 27cbbe32..b8923a35 100644 --- a/src/features/positions/components/supplied-markets-detail.tsx +++ b/src/features/positions/components/supplied-markets-detail.tsx @@ -9,6 +9,8 @@ import { useRateLabel } from '@/hooks/useRateLabel'; import { formatReadable, formatBalance } from '@/utils/balance'; import type { MarketPosition, GroupedPosition } from '@/utils/types'; import { getCollateralColor } from '@/features/positions/utils/colors'; +import { SuppliedAmountCell } from './supplied-amount-cell'; +import { SuppliedPercentageCell } from './supplied-percentage-cell'; type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; setShowWithdrawModal: (show: boolean) => void; @@ -74,21 +76,16 @@ function MarketRow({ data-label="Supplied" className="text-center" > - {formatReadable(suppliedAmount)} {position.market.loanAsset.symbol} + -
-
-
-
- {formatReadable(percentageOfPortfolio)}% -
+ @@ -265,7 +265,7 @@ export function SuppliedMorphoBlueGroupedTable({ Collateral - Warnings + Risk TiersActions @@ -344,7 +344,7 @@ export function SuppliedMorphoBlueGroupedTable({ />
@@ -375,7 +375,7 @@ export function SuppliedMorphoBlueGroupedTable({ {expandedRows.has(rowKey) && ( - + +
+
+
+ {formatReadable(percentage)}% +
+ ); +} diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx new file mode 100644 index 00000000..f1e6acc5 --- /dev/null +++ b/src/features/positions/components/user-vaults-table.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import Image from 'next/image'; +import { ReloadIcon } from '@radix-ui/react-icons'; +import { formatUnits } from 'viem'; +import { Tooltip } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import { useTokens } from '@/components/providers/TokenProvider'; +import { useMarkets } from '@/hooks/useMarkets'; +import { useRateLabel } from '@/hooks/useRateLabel'; +import { formatReadable } from '@/utils/balance'; +import { getNetworkImg } from '@/utils/networks'; +import { parseCapIdParams } from '@/utils/morpho'; +import { convertApyToApr } from '@/utils/rateMath'; +import { VaultAllocationDetail } from './vault-allocation-detail'; +import { CollateralIconsDisplay } from './collateral-icons-display'; +import { VaultActionsDropdown } from './vault-actions-dropdown'; +import { AggregatedVaultRiskIndicators } from './vault-risk-indicators'; + +type UserVaultsTableProps = { + vaults: UserVaultV2[]; + account: string; + refetch?: () => void; + isRefetching?: boolean; +}; + +export function UserVaultsTable({ vaults, account, refetch, isRefetching = false }: UserVaultsTableProps) { + const [expandedRows, setExpandedRows] = useState>(new Set()); + const { findToken } = useTokens(); + const { isAprDisplay } = useMarkets(); + const { short: rateLabel } = useRateLabel(); + + const toggleRow = (rowKey: string) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + } + return newSet; + }); + }; + + if (vaults.length === 0) { + return null; + } + + // Header actions (refresh button) + const headerActions = refetch ? ( + + } + > + + + + + ) : undefined; + + return ( +
+ +
+ + + Network + Size + {rateLabel} (now) + Interest Accrued + Collateral + Risk Tiers + Actions + + + + {vaults.map((vault) => { + const rowKey = `${vault.address}-${vault.networkId}`; + const isExpanded = expandedRows.has(rowKey); + const token = findToken(vault.asset, vault.networkId); + const networkImg = getNetworkImg(vault.networkId); + + // Extract unique collateral addresses from caps + const collateralAddresses = vault.caps + .map((cap) => parseCapIdParams(cap.idParams).collateralToken) + .filter((collat) => collat !== undefined); + + const uniqueCollateralAddresses = Array.from(new Set(collateralAddresses)); + + // Transform to format expected by CollateralIconsDisplay + const collaterals = uniqueCollateralAddresses + .map((address) => { + const collateralToken = findToken(address, vault.networkId); + return { + address, + symbol: collateralToken?.symbol ?? 'Unknown', + amount: 1, // Use 1 as placeholder since we're just showing presence + }; + }) + .filter((c) => c !== null); + + const avgApy = vault.avgApy; + const displayRate = avgApy !== null && avgApy !== undefined && isAprDisplay ? convertApyToApr(avgApy) : avgApy; + + return ( + <> + toggleRow(rowKey)} + > + {/* Network */} + +
+ {networkImg && ( + + )} +
+
+ + {/* Size */} + +
+ + {vault.balance && token ? formatReadable(formatUnits(BigInt(vault.balance), token.decimals)) : '0'} + + {token?.symbol ?? 'USDC'} + +
+
+ + {/* APY/APR */} + +
+ + {displayRate !== null && displayRate !== undefined ? `${(displayRate * 100).toFixed(2)}%` : '—'} + +
+
+ + {/* Interest Accrued - TODO: implement vault earnings calculation */} + +
+ - +
+
+ + {/* Collateral */} + + + + + {/* Risk Tiers */} + +
+ +
+
+ + {/* Actions */} + +
+ +
+
+
+ + {/* Expanded allocation detail */} + + {isExpanded && ( + + + + + + + + )} + + + ); + })} +
+
+
+
+ ); +} diff --git a/src/features/positions/components/vault-actions-dropdown.tsx b/src/features/positions/components/vault-actions-dropdown.tsx new file mode 100644 index 00000000..96224462 --- /dev/null +++ b/src/features/positions/components/vault-actions-dropdown.tsx @@ -0,0 +1,73 @@ +'use client'; + +import type React from 'react'; +import { useRouter } from 'next/navigation'; +import { GoHistory } from 'react-icons/go'; +import { IoEllipsisVertical } from 'react-icons/io5'; +import { MdOutlineSettings } from 'react-icons/md'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; + +type VaultActionsDropdownProps = { + vaultAddress: string; + chainId: number; + account: string; +}; + +export function VaultActionsDropdown({ vaultAddress, chainId, account }: VaultActionsDropdownProps) { + const router = useRouter(); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Stop propagation on keyboard events too + e.stopPropagation(); + }; + + const handleManageClick = () => { + router.push(`/autovault/${chainId}/${vaultAddress}`); + }; + + const handleHistoryClick = () => { + const historyUrl = `/history/${account}?chainId=${chainId}`; + router.push(historyUrl); + }; + + return ( +
+ + + + + + } + > + Manage + + + } + > + History + + + +
+ ); +} diff --git a/src/features/positions/components/vault-allocation-detail.tsx b/src/features/positions/components/vault-allocation-detail.tsx new file mode 100644 index 00000000..9abc15f1 --- /dev/null +++ b/src/features/positions/components/vault-allocation-detail.tsx @@ -0,0 +1,217 @@ +import { useMemo } from 'react'; +import type { Address } from 'viem'; +import { motion } from 'framer-motion'; +import { Button } from '@/components/ui/button'; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { Spinner } from '@/components/ui/spinner'; +import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { MarketIndicators } from '@/features/markets/components/market-indicators'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import { useRateLabel } from '@/hooks/useRateLabel'; +import { useVaultAllocations } from '@/hooks/useVaultAllocations'; +import { RateFormatted } from '@/components/shared/rate-formatted'; +import { formatBalance } from '@/utils/balance'; +import { parseCapIdParams } from '@/utils/morpho'; +import { SuppliedAmountCell } from './supplied-amount-cell'; +import { SuppliedPercentageCell } from './supplied-percentage-cell'; + +type VaultAllocationDetailProps = { + vault: UserVaultV2; +}; + +export function VaultAllocationDetail({ vault }: VaultAllocationDetailProps) { + const { short: rateLabel } = useRateLabel(); + + // Separate collateral and market caps + const { collateralCaps, marketCaps } = useMemo(() => { + const collat: typeof vault.caps = []; + const market: typeof vault.caps = []; + + vault.caps.forEach((cap) => { + const params = parseCapIdParams(cap.idParams); + if (params.type === 'collateral') { + collat.push(cap); + } else if (params.type === 'market') { + market.push(cap); + } + }); + + return { collateralCaps: collat, marketCaps: market }; + }, [vault.caps]); + + // Fetch actual allocations + const { marketAllocations, loading } = useVaultAllocations({ + collateralCaps, + marketCaps, + vaultAddress: vault.address as Address, + chainId: vault.networkId, + enabled: true, + }); + + // Calculate total allocation for percentage calculation + const totalAllocation = useMemo(() => { + return marketAllocations.reduce((sum, a) => sum + a.allocation, 0n); + }, [marketAllocations]); + + // Get vault asset token info for display + const vaultAssetDecimals = marketAllocations[0]?.market.loanAsset.decimals ?? 18; + const vaultAssetSymbol = marketAllocations[0]?.market.loanAsset.symbol ?? vault.asset; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (marketAllocations.length === 0) { + return ( + +
+ No market allocations configured for this vault. +
+
+ ); + } + + return ( + +
+ + + + Market + Collateral & Parameters + {rateLabel} + Supplied + % of Portfolio + Indicators + Actions + + + + {marketAllocations.map((allocation) => { + // Calculate allocated amount + const allocatedAmount = Number(formatBalance(allocation.allocation, vaultAssetDecimals)); + + // Calculate percentage + const percentage = + totalAllocation > 0n ? (allocatedAmount / Number(formatBalance(totalAllocation, vaultAssetDecimals))) * 100 : 0; + + return ( + + {/* Market ID Badge */} + +
+ +
+
+ + {/* Collateral & Parameters */} + + + + + {/* APY/APR */} + + + + + {/* Supplied */} + + + + + {/* % of Portfolio */} + + + + + {/* Indicators */} + + + + + {/* Actions */} + +
+ +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/features/positions/components/vault-risk-indicators.tsx b/src/features/positions/components/vault-risk-indicators.tsx new file mode 100644 index 00000000..c1d312ed --- /dev/null +++ b/src/features/positions/components/vault-risk-indicators.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import { RiskIndicator } from '@/features/markets/components/risk-indicator'; +import { useMarkets } from '@/hooks/useMarkets'; +import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; +import { parseCapIdParams } from '@/utils/morpho'; +import { type WarningWithDetail, WarningCategory } from '@/utils/types'; + +type AggregatedVaultRiskIndicatorsProps = { + vault: UserVaultV2; +}; + +/** + * Aggregates risk indicators from all markets allocated in a vault. + * Similar to AggregatedRiskIndicators but works with vault data structure. + */ +export function AggregatedVaultRiskIndicators({ vault }: AggregatedVaultRiskIndicatorsProps) { + const { allMarkets } = useMarkets(); + + // Aggregate warnings from all markets in the vault + const uniqueWarnings = useMemo((): WarningWithDetail[] => { + const allWarnings: WarningWithDetail[] = []; + + vault.caps.forEach((cap) => { + const params = parseCapIdParams(cap.idParams); + + // Only process market caps (not collateral caps) + if (params.type === 'market' && params.marketId) { + const market = allMarkets.find((m) => m.uniqueKey.toLowerCase() === params.marketId?.toLowerCase()); + + if (market) { + const marketWarnings = computeMarketWarnings(market, true); + allWarnings.push(...marketWarnings); + } + } + }); + + // Remove duplicates based on warning code + return allWarnings.filter((warning, index, array) => array.findIndex((w) => w.code === warning.code) === index); + }, [vault.caps, allMarkets]); + + // Helper to get warnings by category and determine risk level + const getWarningIndicator = (category: WarningCategory, greenDesc: string, yellowDesc: string, redDesc: string) => { + const categoryWarnings = uniqueWarnings.filter((w) => w.category === category); + + if (categoryWarnings.length === 0) { + return ( + + ); + } + + if (categoryWarnings.some((w) => w.level === 'alert')) { + const alertWarning = categoryWarnings.find((w) => w.level === 'alert'); + return ( + + ); + } + + return ( + + ); + }; + + return ( + <> + {getWarningIndicator(WarningCategory.asset, 'Recognized asset', 'Asset with warning', 'High-risk asset')} + {getWarningIndicator(WarningCategory.oracle, 'Recognized oracles', 'Oracle warning', 'Oracle warning')} + {getWarningIndicator(WarningCategory.debt, 'No bad debt', 'Bad debt has occurred', 'Bad debt higher than 1% of supply')} + + ); +} diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 726e964f..ecb58dc0 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -16,9 +16,11 @@ import { TooltipContent } from '@/components/shared/tooltip-content'; import { useMarkets } from '@/hooks/useMarkets'; import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { usePortfolioValue } from '@/hooks/usePortfolioValue'; +import { useUserVaultsV2 } from '@/hooks/useUserVaultsV2'; import type { MarketPosition } from '@/utils/types'; import { SuppliedMorphoBlueGroupedTable } from './components/supplied-morpho-blue-grouped-table'; import { PortfolioValueBadge } from './components/portfolio-value-badge'; +import { UserVaultsTable } from './components/user-vaults-table'; export default function Positions() { const [showSupplyModal, setShowSupplyModal] = useState(false); @@ -39,8 +41,11 @@ export default function Positions() { loadingStates, } = useUserPositionsSummaryData(account, earningsPeriod); - // Calculate portfolio value from positions - const { totalUsd, isLoading: isPricesLoading, error: pricesError } = usePortfolioValue(marketPositions); + // Fetch user's auto vaults + const { vaults, loading: isVaultsLoading, refetch: refetchVaults } = useUserVaultsV2(account); + + // Calculate portfolio value from positions and vaults + const { totalUsd, isLoading: isPricesLoading, error: pricesError } = usePortfolioValue(marketPositions, vaults); const loading = isMarketsLoading || isPositionsLoading; @@ -55,6 +60,8 @@ export default function Positions() { }, [isMarketsLoading, loadingStates]); const hasSuppliedMarkets = marketPositions && marketPositions.length > 0; + const hasVaults = vaults && vaults.length > 0; + const showEmpty = !loading && !isVaultsLoading && !hasSuppliedMarkets && !hasVaults; const handleRefetch = () => { void refetch(() => toast.info('Data refreshed', { icon: 🚀 })); @@ -73,7 +80,7 @@ export default function Positions() { variant="full" showAddress /> - {!loading && hasSuppliedMarkets && ( + {!loading && (hasSuppliedMarkets || hasVaults) && ( )} - {loading ? ( - - ) : hasSuppliedMarkets ? ( -
+
+ {/* Loading state for initial page load */} + {loading && ( + + )} + + {/* Morpho Blue Positions Section */} + {!loading && hasSuppliedMarkets && ( -
- ) : ( -
-
- - } - > - - - - -
-
- + )} + + {/* Auto Vaults Section (progressive loading) */} + {isVaultsLoading && !loading && ( + + )} + + {!isVaultsLoading && hasVaults && ( + void refetchVaults()} + /> + )} + + {/* Empty state (only if both finished loading and both empty) */} + {showEmpty && ( +
+
+ + } + > + + + + +
+
+ +
-
- )} + )} +
); diff --git a/src/hooks/useUserVaultsV2.ts b/src/hooks/useUserVaultsV2.ts index abdaff74..f69e51e1 100644 --- a/src/hooks/useUserVaultsV2.ts +++ b/src/hooks/useUserVaultsV2.ts @@ -51,14 +51,17 @@ async function fetchAndProcessVaults(address: Address): Promise { return vaultsWithBalances; } -export function useUserVaultsV2(): UseUserVaultsV2Return { - const { address } = useConnection(); +export function useUserVaultsV2(account?: string): UseUserVaultsV2Return { + const { address: connectedAddress } = useConnection(); const [vaults, setVaults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Use provided account or fall back to connected address + const targetAddress = account ?? connectedAddress; + const fetchVaults = useCallback(async () => { - if (!address) { + if (!targetAddress) { setVaults([]); setLoading(false); return; @@ -68,7 +71,7 @@ export function useUserVaultsV2(): UseUserVaultsV2Return { setError(null); try { - const vaultsWithBalances = await fetchAndProcessVaults(address); + const vaultsWithBalances = await fetchAndProcessVaults(targetAddress as Address); setVaults(vaultsWithBalances); } catch (err) { const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); @@ -77,7 +80,7 @@ export function useUserVaultsV2(): UseUserVaultsV2Return { } finally { setLoading(false); } - }, [address]); + }, [targetAddress]); useEffect(() => { void fetchVaults(); From dd025bed67a685fb857204c4e60b51e9b60c55e4 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 18:29:20 +0800 Subject: [PATCH 09/15] chore: fix styles --- src/components/common/table-container-with-header.tsx | 2 +- src/features/positions/components/user-vaults-table.tsx | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/common/table-container-with-header.tsx b/src/components/common/table-container-with-header.tsx index b673f92d..d4e4a028 100644 --- a/src/components/common/table-container-with-header.tsx +++ b/src/components/common/table-container-with-header.tsx @@ -32,7 +32,7 @@ type TableContainerWithHeaderProps = { export function TableContainerWithHeader({ title, actions, children, className = '' }: TableContainerWithHeaderProps) { return (
-
+

{title}

{actions &&
{actions}
}
diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index f1e6acc5..4db05415 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import Image from 'next/image'; import { ReloadIcon } from '@radix-ui/react-icons'; @@ -123,9 +123,8 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false const displayRate = avgApy !== null && avgApy !== undefined && isAprDisplay ? convertApyToApr(avgApy) : avgApy; return ( - <> + toggleRow(rowKey)} > @@ -228,7 +227,7 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false )} - + ); })} From fd491e0a496a7fc56e2a45cabb66cb28b5056ce4 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 22:35:16 +0800 Subject: [PATCH 10/15] fix: vault shares --- src/data-sources/subgraph/v2-vaults.ts | 4 +- .../history/components/history-table.tsx | 2 + .../components/user-vaults-table.tsx | 2 +- src/hooks/useAutovaultData.ts | 218 ------------------ src/hooks/usePortfolioValue.ts | 7 +- src/hooks/useUserVaultsV2.ts | 74 ++++-- src/utils/portfolio.ts | 20 +- src/utils/vaultAllocation.ts | 114 +++++++++ 8 files changed, 191 insertions(+), 250 deletions(-) delete mode 100644 src/hooks/useAutovaultData.ts diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index 8587c51a..fd62fc45 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -1,3 +1,4 @@ +import type { Address } from 'viem'; import type { VaultV2Details } from '@/data-sources/morpho-api/v2-vaults'; import { userVaultsV2AddressesQuery } from '@/graphql/morpho-v2-subgraph-queries'; import { type SupportedNetworks, getAgentConfig, networks, isAgentAvailable } from '@/utils/networks'; @@ -26,7 +27,8 @@ export type UserVaultV2Address = { // This is used by the autovault page to display user's vaults export type UserVaultV2 = VaultV2Details & { networkId: SupportedNetworks; - balance?: bigint; + balance?: bigint; // User's redeemable assets (from previewRedeem) + adapter?: Address; // MorphoMarketV1Adapter address }; /** diff --git a/src/features/history/components/history-table.tsx b/src/features/history/components/history-table.tsx index 3233afa3..8ee8d201 100644 --- a/src/features/history/components/history-table.tsx +++ b/src/features/history/components/history-table.tsx @@ -1,3 +1,5 @@ +'use client'; + import type React from 'react'; import { useMemo, useState, useRef, useEffect } from 'react'; import Link from 'next/link'; diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index 4db05415..20e3c0ac 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -146,7 +146,7 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false
- {vault.balance && token ? formatReadable(formatUnits(BigInt(vault.balance), token.decimals)) : '0'} + {vault.balance && token ? formatReadable(formatUnits(vault.balance, token.decimals)) : '0'} {token?.symbol ?? 'USDC'} { - const safeAddress = address ?? ZERO_ADDRESS; - return { - id: 'empty', - address: safeAddress, - name: '', - symbol: '', - description: '', - totalValue: 0n, - currentApy: 0, - agents: [], - status: 'inactive', - owner: ZERO_ADDRESS, - createdAt: new Date(0), - lastActivity: new Date(0), - rebalanceHistory: [], - allocations: [], - }; -}; - -type UseAutovaultDataResult = { - autovaults: AutovaultData[]; - isLoading: boolean; - isError: boolean; - error: Error | null; - refetch: () => Promise; -}; - -export function useAutovaultData(account?: Address): UseAutovaultDataResult { - const [autovaults, setAutovaults] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - const [error, setError] = useState(null); - - const fetchAutovaultData = async () => { - if (!account) { - setAutovaults([]); - setIsLoading(false); - return; - } - - try { - setIsLoading(true); - setIsError(false); - setError(null); - - // TODO: Replace with actual API call - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // TODO: Implement actual autovault data fetching - // This should fetch vaults owned by the specific address - // Query your smart contracts or backend API - const mockData: AutovaultData[] = []; - - // Filter to only include vaults owned by the connected address - const ownedVaults = mockData.filter((vault) => vault.owner.toLowerCase() === account.toLowerCase()); - - setAutovaults(ownedVaults); - } catch (err) { - setIsError(true); - setError(err instanceof Error ? err : new Error('Failed to fetch autovault data')); - } finally { - setIsLoading(false); - } - }; - - const refetch = async () => { - await fetchAutovaultData(); - }; - - useEffect(() => { - void fetchAutovaultData(); - }, [account]); - - return { - autovaults, - isLoading, - isError, - error, - refetch, - }; -} - -// Hook to check if user has any active autovaults -export function useHasActiveAutovaults(account?: Address): { - hasActiveVaults: boolean; - isLoading: boolean; -} { - const { autovaults, isLoading } = useAutovaultData(account); - - const hasActiveVaults = autovaults.some((vault) => vault.status === 'active'); - - return { - hasActiveVaults, - isLoading, - }; -} - -// Hook to get specific vault details by vault address -export function useVaultDetails(vaultAddress?: Address): { - vault: AutovaultData; - isLoading: boolean; - isError: boolean; - error: Error | null; - refetch: () => Promise; -} { - const [vault, setVault] = useState(() => createEmptyVault(vaultAddress)); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - const [error, setError] = useState(null); - - const fetchVaultDetails = async () => { - if (!vaultAddress) { - setVault(createEmptyVault()); - setIsLoading(false); - return; - } - - try { - setIsLoading(true); - setIsError(false); - setError(null); - - // TODO: Replace with actual API call - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 800)); - - // TODO: Implement actual vault details fetching - // This should fetch vault details from your smart contracts - // const vaultData = await fetchVaultFromContract(vaultAddress); - - // Mock data - replace with actual implementation - const mockVault: AutovaultData | null = null; - - setVault(mockVault ?? createEmptyVault(vaultAddress)); - } catch (err) { - setIsError(true); - setError(err instanceof Error ? err : new Error('Failed to fetch vault details')); - setVault(createEmptyVault(vaultAddress)); - } finally { - setIsLoading(false); - } - }; - - const refetch = async () => { - await fetchVaultDetails(); - }; - - useEffect(() => { - setVault(createEmptyVault(vaultAddress)); - void fetchVaultDetails(); - }, [vaultAddress]); - - return { - vault, - isLoading, - isError, - error, - refetch, - }; -} diff --git a/src/hooks/usePortfolioValue.ts b/src/hooks/usePortfolioValue.ts index 85079482..d89135e3 100644 --- a/src/hooks/usePortfolioValue.ts +++ b/src/hooks/usePortfolioValue.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useTokens } from '@/components/providers/TokenProvider'; import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; import type { MarketPositionWithEarnings } from '@/utils/types'; import { calculatePortfolioValue, extractTokensFromPositions, extractTokensFromVaults } from '@/utils/portfolio'; @@ -19,6 +20,8 @@ type UsePortfolioValueReturn = { * @returns Portfolio value breakdown and loading/error states */ export const usePortfolioValue = (positions: MarketPositionWithEarnings[], vaults?: UserVaultV2[]): UsePortfolioValueReturn => { + const { findToken } = useTokens(); + // Extract unique tokens from all sources const tokens = useMemo(() => { const positionTokens = extractTokensFromPositions(positions); @@ -41,8 +44,8 @@ export const usePortfolioValue = (positions: MarketPositionWithEarnings[], vault }; } - return calculatePortfolioValue(positions, vaults, prices); - }, [positions, vaults, prices, isLoading]); + return calculatePortfolioValue(positions, vaults, prices, findToken); + }, [positions, vaults, prices, isLoading, findToken]); return { totalUsd: portfolioValue.total, diff --git a/src/hooks/useUserVaultsV2.ts b/src/hooks/useUserVaultsV2.ts index f69e51e1..f0c17edb 100644 --- a/src/hooks/useUserVaultsV2.ts +++ b/src/hooks/useUserVaultsV2.ts @@ -1,9 +1,12 @@ import { useState, useEffect, useCallback } from 'react'; import type { Address } from 'viem'; import { useConnection } from 'wagmi'; +import { fetchMorphoMarketV1Adapters } from '@/data-sources/subgraph/morpho-market-v1-adapters'; import { fetchMultipleVaultV2DetailsAcrossNetworks } from '@/data-sources/morpho-api/v2-vaults'; import { fetchUserVaultV2AddressesAllNetworks, type UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; -import { readTotalAsset } from '@/utils/vaultAllocation'; +import { getMorphoAddress } from '@/utils/morpho'; +import { getNetworkConfig } from '@/utils/networks'; +import { fetchUserVaultShares } from '@/utils/vaultAllocation'; type UseUserVaultsV2Return = { vaults: UserVaultV2[]; @@ -16,24 +19,9 @@ function filterValidVaults(vaults: UserVaultV2[]): UserVaultV2[] { return vaults.filter((vault) => vault.owner && vault.asset && vault.address); } -async function fetchVaultBalances(vaults: UserVaultV2[]): Promise { - return Promise.all( - vaults.map(async (vault) => { - const balance = await readTotalAsset(vault.address as Address, vault.networkId); - - return { - ...vault, - balance: balance ?? BigInt(0), - }; - }), - ); -} - -async function fetchAndProcessVaults(address: Address): Promise { +async function fetchAndProcessVaults(userAddress: Address): Promise { // Step 1: Fetch vault addresses from subgraph across all networks - const vaultAddresses = await fetchUserVaultV2AddressesAllNetworks(address); - - console.log('vaultAddresses', vaultAddresses); + const vaultAddresses = await fetchUserVaultV2AddressesAllNetworks(userAddress); if (vaultAddresses.length === 0) { return []; @@ -45,10 +33,54 @@ async function fetchAndProcessVaults(address: Address): Promise { // Step 3: Filter valid vaults const validVaults = filterValidVaults(vaultDetails as UserVaultV2[]); - // Step 4: Fetch balances for each vault - const vaultsWithBalances = await fetchVaultBalances(validVaults); + if (validVaults.length === 0) { + return []; + } + + // Step 4: Batch fetch adapters from subgraph for each vault + const adapterPromises = validVaults.map(async (vault) => { + const networkConfig = getNetworkConfig(vault.networkId); + const subgraphUrl = networkConfig?.vaultConfig?.adapterSubgraphEndpoint; + + if (!subgraphUrl) { + return { vaultAddress: vault.address, adapter: undefined }; + } + + try { + const morphoAddress = getMorphoAddress(vault.networkId); + const adapters = await fetchMorphoMarketV1Adapters({ + subgraphUrl, + parentVault: vault.address as Address, + morpho: morphoAddress as Address, + }); + + return { + vaultAddress: vault.address, + adapter: adapters.length > 0 ? adapters[0].adapter : undefined, + }; + } catch (error) { + console.error(`Failed to fetch adapter for vault ${vault.address}:`, error); + return { vaultAddress: vault.address, adapter: undefined }; + } + }); + + const adapterResults = await Promise.all(adapterPromises); + const adapterMap = new Map(adapterResults.map((r) => [r.vaultAddress.toLowerCase(), r.adapter])); + + // Step 5: Batch fetch user's share balances via multicall + const shareBalances = await fetchUserVaultShares( + validVaults.map((v) => ({ address: v.address as Address, networkId: v.networkId })), + userAddress, + ); + + // Step 6: Combine all data + const vaultsWithBalancesAndAdapters = validVaults.map((vault) => ({ + ...vault, + adapter: adapterMap.get(vault.address.toLowerCase()), + balance: shareBalances.get(vault.address.toLowerCase()) ?? 0n, + })); - return vaultsWithBalances; + return vaultsWithBalancesAndAdapters; } export function useUserVaultsV2(account?: string): UseUserVaultsV2Return { diff --git a/src/utils/portfolio.ts b/src/utils/portfolio.ts index 2f7a8a26..6fb16da4 100644 --- a/src/utils/portfolio.ts +++ b/src/utils/portfolio.ts @@ -111,15 +111,19 @@ export const positionsToBalances = (positions: MarketPositionWithEarnings[]): To /** * Convert vaults to token balances * @param vaults - Array of user vaults + * @param findToken - Function to find token metadata * @returns Array of token balances */ -export const vaultsToBalances = (vaults: UserVaultV2[]): TokenBalance[] => { +export const vaultsToBalances = ( + vaults: UserVaultV2[], + findToken: (address: string, chainId: number) => { decimals: number } | undefined, +): TokenBalance[] => { return vaults - .filter((vault) => vault.balance !== undefined) + .filter((vault) => vault.balance !== undefined && vault.balance > 0n) .map((vault) => { - // Find the asset decimals from the vault data - // Default to 18 if not available (most ERC20 tokens use 18 decimals) - const decimals = 18; + // Get decimals from token metadata + const token = findToken(vault.asset, vault.networkId); + const decimals = token?.decimals ?? 18; return { tokenAddress: vault.asset, @@ -135,12 +139,14 @@ export const vaultsToBalances = (vaults: UserVaultV2[]): TokenBalance[] => { * @param positions - Array of market positions * @param vaults - Array of user vaults (optional) * @param prices - Map of token prices + * @param findToken - Function to find token metadata (required for vaults) * @returns Portfolio value with breakdown */ export const calculatePortfolioValue = ( positions: MarketPositionWithEarnings[], vaults: UserVaultV2[] | undefined, prices: Map, + findToken?: (address: string, chainId: number) => { decimals: number } | undefined, ): PortfolioValue => { // Convert positions to balances const positionBalances = positionsToBalances(positions); @@ -148,8 +154,8 @@ export const calculatePortfolioValue = ( // Convert vaults to balances (if provided) let vaultsUsd = 0; - if (vaults && vaults.length > 0) { - const vaultBalances = vaultsToBalances(vaults); + if (vaults && vaults.length > 0 && findToken) { + const vaultBalances = vaultsToBalances(vaults, findToken); vaultsUsd = calculateUsdValue(vaultBalances, prices); } diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts index 35e6061b..c96a6166 100644 --- a/src/utils/vaultAllocation.ts +++ b/src/utils/vaultAllocation.ts @@ -63,3 +63,117 @@ export function calculateAllocationPercent(amount: bigint, total: bigint): strin const percent = (Number(amount) / Number(total)) * 100; return percent.toFixed(2); } + +/** + * Batch fetch user's vault shares and convert to redeemable assets + * @param vaults - Array of vaults with address and networkId + * @param userAddress - User's address + * @returns Map of vault address to redeemable assets (previewRedeem result) + */ +export async function fetchUserVaultShares( + vaults: { address: Address; networkId: SupportedNetworks }[], + userAddress: Address, +): Promise> { + // Group vaults by network for efficient batching + const vaultsByNetwork = vaults.reduce( + (acc, vault) => { + if (!acc[vault.networkId]) { + acc[vault.networkId] = []; + } + acc[vault.networkId].push(vault.address); + return acc; + }, + {} as Record, + ); + + const results = new Map(); + + // Process each network in parallel + await Promise.all( + Object.entries(vaultsByNetwork).map(async ([networkIdStr, vaultAddresses]) => { + const networkId = Number(networkIdStr) as SupportedNetworks; + const client = getClient(networkId); + + try { + // Step 1: Batch fetch balanceOf for all vaults + const balanceContracts = vaultAddresses.map((vaultAddress) => ({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'balanceOf' as const, + args: [userAddress], + })); + + const balanceResults = await client.multicall({ + contracts: balanceContracts, + allowFailure: true, + }); + + // Step 2: Batch fetch previewRedeem for vaults with non-zero balance + const redeemContracts = vaultAddresses + .map((vaultAddress, index) => { + const balanceResult = balanceResults[index]; + if (balanceResult.status === 'success' && balanceResult.result) { + const shares = balanceResult.result as bigint; + if (shares > 0n) { + return { + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'previewRedeem' as const, + args: [shares], + _vaultAddress: vaultAddress, + }; + } + } + return null; + }) + .filter((c) => c !== null); + + if (redeemContracts.length === 0) { + // No vaults with balance, return zeros + vaultAddresses.forEach((addr) => { + results.set(addr.toLowerCase(), 0n); + }); + return; + } + + const redeemResults = await client.multicall({ + contracts: redeemContracts.map((c) => ({ + address: c!.address, + abi: c!.abi, + functionName: c!.functionName, + args: c!.args, + })), + allowFailure: true, + }); + + // Map results back to vault addresses + redeemContracts.forEach((contract, index) => { + if (contract) { + const result = redeemResults[index]; + const vaultAddress = contract._vaultAddress.toLowerCase(); + if (result.status === 'success' && result.result) { + results.set(vaultAddress, result.result as bigint); + } else { + results.set(vaultAddress, 0n); + } + } + }); + + // Set 0 for vaults that had 0 balance + vaultAddresses.forEach((addr) => { + if (!results.has(addr.toLowerCase())) { + results.set(addr.toLowerCase(), 0n); + } + }); + } catch (error) { + console.error(`Failed to fetch vault shares for network ${networkId}:`, error); + // Set all to 0 on error + vaultAddresses.forEach((addr) => { + results.set(addr.toLowerCase(), 0n); + }); + } + }), + ); + + return results; +} From d2871dec94f8511c0af50855d5ae131ca4d1da29 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 22 Dec 2025 23:54:34 +0800 Subject: [PATCH 11/15] chore: padding --- src/components/ui/icon-switch.tsx | 6 +++++- .../components/supplied-morpho-blue-grouped-table.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ui/icon-switch.tsx b/src/components/ui/icon-switch.tsx index d9099ec6..1ae86074 100644 --- a/src/components/ui/icon-switch.tsx +++ b/src/components/ui/icon-switch.tsx @@ -241,7 +241,11 @@ export function IconSwitch({ classNames?.thumbIcon, )} > - + {thumbIconOn && thumbIconOff ? ( + + ) : ( + + )} )} diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 9b47f6a2..13b77f58 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -231,7 +231,7 @@ export function SuppliedMorphoBlueGroupedTable({ ); return ( -
+
Date: Tue, 23 Dec 2025 00:09:49 +0800 Subject: [PATCH 12/15] chore: remove unused query --- src/graphql/morpho-api-queries.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts index 55ee6291..951060a3 100644 --- a/src/graphql/morpho-api-queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -131,15 +131,6 @@ badDebt { supplyingVaults { address } - -riskAnalysis { - analysis { - ... on CredoraRiskAnalysis { - score - rating - } - } -} `; // Market Fragement is only used type when querying a single market @@ -237,14 +228,6 @@ export const marketsQuery = ` level __typename } - riskAnalysis { - analysis { - ... on CredoraRiskAnalysis { - score - rating - } - } - } } `; From 5d25a3a4627999cc66a4c3c7efbfd6bc4350eba8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 23 Dec 2025 00:32:44 +0800 Subject: [PATCH 13/15] misc: better loading state for portfolio value --- .../positions/components/portfolio-value-badge.tsx | 13 ++++++++++--- src/features/positions/positions-view.tsx | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/features/positions/components/portfolio-value-badge.tsx b/src/features/positions/components/portfolio-value-badge.tsx index 5f0c037a..2a326746 100644 --- a/src/features/positions/components/portfolio-value-badge.tsx +++ b/src/features/positions/components/portfolio-value-badge.tsx @@ -1,4 +1,5 @@ import { formatUsdValue } from '@/utils/portfolio'; +import { PulseLoader } from 'react-spinners'; type PortfolioValueBadgeProps = { totalUsd: number; @@ -16,11 +17,17 @@ export function PortfolioValueBadge({ totalUsd, isLoading, error, onClick }: Por > Total Value {isLoading ? ( - Calculating... +
+ +
) : error ? ( - + ) : ( - {formatUsdValue(totalUsd)} + {formatUsdValue(totalUsd)} )} ); diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index ecb58dc0..c257c179 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -114,7 +114,7 @@ export default function Positions() { /> )} -
+
{/* Loading state for initial page load */} {loading && ( Date: Tue, 23 Dec 2025 11:32:28 +0800 Subject: [PATCH 14/15] feat: unify tables --- .../components/market-risk-indicators.tsx | 34 +++++++++ .../components/table/market-table-body.tsx | 8 +- .../positions/components/allocation-cell.tsx | 76 +++++++++++++++++++ .../components/supplied-amount-cell.tsx | 18 ----- .../components/supplied-markets-detail.tsx | 64 ++++++---------- .../components/supplied-percentage-cell.tsx | 23 ------ .../components/vault-actions-dropdown.tsx | 14 ++-- .../components/vault-allocation-detail.tsx | 73 +++++++----------- src/features/positions/positions-view.tsx | 2 +- 9 files changed, 170 insertions(+), 142 deletions(-) create mode 100644 src/features/markets/components/market-risk-indicators.tsx create mode 100644 src/features/positions/components/allocation-cell.tsx delete mode 100644 src/features/positions/components/supplied-amount-cell.tsx delete mode 100644 src/features/positions/components/supplied-percentage-cell.tsx diff --git a/src/features/markets/components/market-risk-indicators.tsx b/src/features/markets/components/market-risk-indicators.tsx new file mode 100644 index 00000000..98ef9acb --- /dev/null +++ b/src/features/markets/components/market-risk-indicators.tsx @@ -0,0 +1,34 @@ +import type { Market } from '@/utils/types'; +import { MarketAssetIndicator, MarketOracleIndicator, MarketDebtIndicator } from './risk-indicator'; + +type MarketRiskIndicatorsProps = { + market: Market; + isBatched?: boolean; + mode?: 'simple' | 'complex'; +}; + +/** + * Standard risk indicators component showing asset, oracle, and debt risk tiers. + * This component provides a consistent way to display market risk across the application. + */ +export function MarketRiskIndicators({ market, isBatched = false, mode = 'simple' }: MarketRiskIndicatorsProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 336b86d2..5e311731 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -5,6 +5,7 @@ import { TableBody, TableRow, TableCell } from '@/components/ui/table'; import { RateFormatted } from '@/components/shared/rate-formatted'; import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; import { MarketIndicators } from '@/features/markets/components/market-indicators'; +import { MarketRiskIndicators } from '@/features/markets/components/market-risk-indicators'; import OracleVendorBadge from '@/features/markets/components/oracle-vendor-badge'; import { TrustedByCell } from '@/features/autovault/components/trusted-vault-badges'; import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; @@ -15,7 +16,6 @@ import type { ColumnVisibility } from '../column-visibility'; import { MarketActionsDropdown } from '../market-actions-dropdown'; import { ExpandedMarketDetail } from './market-row-detail'; import { TDAsset, TDTotalSupplyOrBorrow } from './market-table-utils'; -import { MarketAssetIndicator, MarketOracleIndicator, MarketDebtIndicator } from '../risk-indicator'; type MarketTableBodyProps = { currentEntries: Market[]; @@ -237,11 +237,7 @@ export function MarketTableBody({ )} -
- - - -
+
+ {/* Amount and symbol */} + + {isZero ? '0' : formatReadable(amount)} {symbol} + + + {/* Circular percentage indicator */} + + } + > +
+ + {/* Background circle */} + + {/* Progress circle */} + + +
+
+
+ ); +} diff --git a/src/features/positions/components/supplied-amount-cell.tsx b/src/features/positions/components/supplied-amount-cell.tsx deleted file mode 100644 index 705ef9fc..00000000 --- a/src/features/positions/components/supplied-amount-cell.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { formatReadable } from '@/utils/balance'; - -type SuppliedAmountCellProps = { - amount: number; - symbol: string; -}; - -/** - * Shared component for displaying supplied amount in expanded tables. - * Used by both Morpho Blue and Vault allocation details. - */ -export function SuppliedAmountCell({ amount, symbol }: SuppliedAmountCellProps) { - return ( - <> - {formatReadable(amount)} {symbol} - - ); -} diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx index b8923a35..a586ba5e 100644 --- a/src/features/positions/components/supplied-markets-detail.tsx +++ b/src/features/positions/components/supplied-markets-detail.tsx @@ -1,16 +1,14 @@ import { motion } from 'framer-motion'; import { Button } from '@/components/ui/button'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { RateFormatted } from '@/components/shared/rate-formatted'; -import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; -import { MarketIndicators } from '@/features/markets/components/market-indicators'; +import { MarketRiskIndicators } from '@/features/markets/components/market-risk-indicators'; +import { APYCell } from '@/features/markets/components/apy-breakdown-tooltip'; import { useRateLabel } from '@/hooks/useRateLabel'; import { formatReadable, formatBalance } from '@/utils/balance'; import type { MarketPosition, GroupedPosition } from '@/utils/types'; import { getCollateralColor } from '@/features/positions/utils/colors'; -import { SuppliedAmountCell } from './supplied-amount-cell'; -import { SuppliedPercentageCell } from './supplied-percentage-cell'; +import { AllocationCell } from './allocation-cell'; type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; setShowWithdrawModal: (show: boolean) => void; @@ -44,63 +42,50 @@ function MarketRow({ > -
- -
-
- - + - - - - - -
+