diff --git a/.claude/skills/icons/SKILL.md b/.claude/skills/icons/SKILL.md index 330240b3..95b534c2 100644 --- a/.claude/skills/icons/SKILL.md +++ b/.claude/skills/icons/SKILL.md @@ -35,9 +35,12 @@ description: Icon usage conventions and semantic mappings. Use when adding, modi | Concept | Icon | Import | |---------|------|--------| +| Supply | `BsArrowUpCircle` | `react-icons/bs` | +| Deposit | `BsArrowUpCircle` | `react-icons/bs` | +| Borrow | `BsArrowDownLeftCircle` | `react-icons/bs` | +| Repay | `BsArrowUpRightCircle` | `react-icons/bs` | +| Withdraw | `BsArrowDownCircle` | `react-icons/bs` | | Swap | `LuArrowRightLeft` | `react-icons/lu` | -| Withdraw | `BsArrowUpCircle` | `react-icons/bs` | -| Deposit | `BsArrowDownCircle` | `react-icons/bs` | | History | `GoHistory` | `react-icons/go` | | Rewards | `FiGift` | `react-icons/fi` | | Fire | `HiFire` | `react-icons/hi2` | diff --git a/app/global.css b/app/global.css index bb662242..01d88dea 100644 --- a/app/global.css +++ b/app/global.css @@ -294,6 +294,13 @@ color-scheme: dark; } +.dark .shadow-sm, +.dark .shadow-md, +.dark .shadow-lg { + box-shadow: none; /* Kill the shadow in dark mode */ + border: 1px solid rgba(255, 255, 255, 0.08); /* Add a subtle white ring */ +} + *, *::before, *::after { diff --git a/src/components/Info/info.tsx b/src/components/Info/info.tsx index 94d4616e..c0a34a28 100644 --- a/src/components/Info/info.tsx +++ b/src/components/Info/info.tsx @@ -6,13 +6,13 @@ import { IoWarningOutline } from 'react-icons/io5'; const levelToCellColor = (level: string) => { switch (level) { case 'info': - return 'bg-blue-200 text-blue-700'; + return 'bg-blue-100 text-blue-800 dark:bg-blue-400/10 dark:text-blue-300'; case 'success': - return 'bg-green-200 text-green-700'; + return 'bg-green-100 text-green-800 dark:bg-green-400/10 dark:text-green-300'; case 'warning': - return 'bg-yellow-200 text-yellow-700'; + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-300'; case 'alert': - return 'bg-red-200 text-red-700'; + return 'bg-red-100 text-red-800 dark:bg-red-400/10 dark:text-red-300'; default: return ''; } @@ -23,33 +23,33 @@ const levelToIcon = (level: string) => { case 'info': return ( ); case 'success': return ( ); case 'warning': return ( ); case 'alert': return ( ); default: - return ''; + return null; } }; @@ -60,7 +60,7 @@ const levelToIcon = (level: string) => { */ export function Info({ description, level, title }: { description: string; level: string; title?: string }) { return ( -
+
{levelToIcon(level)}
{title &&

{title}

} diff --git a/src/components/layout/header/TransactionIndicator.tsx b/src/components/layout/header/TransactionIndicator.tsx index 60acb0ca..c4d6ff5b 100644 --- a/src/components/layout/header/TransactionIndicator.tsx +++ b/src/components/layout/header/TransactionIndicator.tsx @@ -1,19 +1,19 @@ 'use client'; import { useCallback } from 'react'; -import { FiDownload, FiRepeat } from 'react-icons/fi'; import { LuArrowRightLeft } from 'react-icons/lu'; -import { BsArrowDownCircle, BsArrowUpCircle } from 'react-icons/bs'; +import { BsArrowDownCircle, BsArrowDownLeftCircle, BsArrowUpCircle, BsArrowUpRightCircle } from 'react-icons/bs'; import { useShallow } from 'zustand/shallow'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useTransactionProcessStore } from '@/stores/useTransactionProcessStore'; const TX_TYPE_CONFIG: Record = { supply: { icon: , label: 'Supply' }, - borrow: { icon: , label: 'Borrow' }, - repay: { icon: , label: 'Repay' }, - vaultDeposit: { icon: , label: 'Deposit' }, - deposit: { icon: , label: 'Deposit' }, + borrow: { icon: , label: 'Borrow' }, + repay: { icon: , label: 'Repay' }, + withdraw: { icon: , label: 'Withdraw' }, + vaultDeposit: { icon: , label: 'Deposit' }, + deposit: { icon: , label: 'Deposit' }, wrap: { icon: , label: 'Wrap' }, rebalance: { icon: , label: 'Rebalance' }, }; diff --git a/src/components/shared/address-identity.tsx b/src/components/shared/address-identity.tsx new file mode 100644 index 00000000..2b5b263d --- /dev/null +++ b/src/components/shared/address-identity.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import { Avatar } from '@/components/Avatar/Avatar'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { getExplorerURL } from '@/utils/external'; + +type AddressIdentityProps = { + address: string; + chainId: number; + label?: string; + isToken?: boolean; + tokenSymbol?: string; +}; + +/** + * Use to display address, not Account. Better used for contracts + * @param param0 + * @returns + */ +export function AddressIdentity({ address, chainId, label, isToken, tokenSymbol }: AddressIdentityProps) { + return ( + + {isToken ? ( + + ) : ( + + )} + {label && {label}} + + {address.slice(0, 6)}...{address.slice(-4)} + + + + ); +} diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx index bb556e4f..5997b112 100644 --- a/src/components/ui/button-group.tsx +++ b/src/components/ui/button-group.tsx @@ -15,6 +15,7 @@ type ButtonGroupProps = { onChange: (value: ButtonOption['value']) => void; size?: 'sm' | 'md' | 'lg'; variant?: 'default' | 'primary'; + equalWidth?: boolean; }; const sizeClasses = { @@ -41,7 +42,7 @@ const variantStyles = { ], }; -export default function ButtonGroup({ options, value, onChange, size = 'md', variant = 'default' }: ButtonGroupProps) { +export default function ButtonGroup({ options, value, onChange, size = 'md', variant = 'default', equalWidth = false }: ButtonGroupProps) { return (
{ + if (!filterType) return activeCampaigns; + + const allowedTypes = filterType === 'supply' ? SUPPLY_CAMPAIGN_TYPES : BORROW_CAMPAIGN_TYPES; + return activeCampaigns.filter((campaign) => allowedTypes.includes(campaign.type)); + }, [activeCampaigns, filterType]); + + if (loading || !hasActiveRewards || filteredCampaigns.length === 0) { return null; } - const totalBonus = activeCampaigns.reduce((sum, campaign) => sum + campaign.apr, 0); + const totalBonus = filteredCampaigns.reduce((sum, campaign) => sum + campaign.apr, 0); return ( <> @@ -52,7 +66,7 @@ export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted setIsModalOpen(false)} - campaigns={activeCampaigns} + campaigns={filteredCampaigns} /> ); diff --git a/src/features/market-detail/components/charts/chart-utils.tsx b/src/features/market-detail/components/charts/chart-utils.tsx new file mode 100644 index 00000000..2d548904 --- /dev/null +++ b/src/features/market-detail/components/charts/chart-utils.tsx @@ -0,0 +1,125 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { CHART_COLORS } from '@/constants/chartColors'; + +export const TIMEFRAME_LABELS: Record = { + '1d': '1D', + '7d': '7D', + '30d': '30D', +}; + +type GradientConfig = { + id: string; + color: string; +}; + +export function ChartGradients({ prefix, gradients }: { prefix: string; gradients: GradientConfig[] }) { + return ( + + {gradients.map(({ id, color }) => ( + + + + + ))} + + ); +} + +export const RATE_CHART_GRADIENTS: GradientConfig[] = [ + { id: 'supplyGradient', color: CHART_COLORS.supply.stroke }, + { id: 'borrowGradient', color: CHART_COLORS.borrow.stroke }, + { id: 'targetGradient', color: CHART_COLORS.apyAtTarget.stroke }, +]; + +export const VOLUME_CHART_GRADIENTS: GradientConfig[] = [ + { id: 'supplyGradient', color: CHART_COLORS.supply.stroke }, + { id: 'borrowGradient', color: CHART_COLORS.borrow.stroke }, + { id: 'liquidityGradient', color: CHART_COLORS.apyAtTarget.stroke }, +]; + +type ChartTooltipContentProps = { + active?: boolean; + payload?: any[]; + label?: number; + formatValue: (value: number) => string; +}; + +export function ChartTooltipContent({ active, payload, label, formatValue }: ChartTooltipContentProps) { + if (!active || !payload) return null; + return ( +
+

{new Date((label ?? 0) * 1000).toLocaleDateString()}

+
+ {payload.map((entry: any) => ( +
+
+ + {entry.name} +
+ {formatValue(entry.value)} +
+ ))} +
+
+ ); +} + +type ChartLegendProps> = { + visibleLines: T; + setVisibleLines: Dispatch>; +}; + +export function createLegendClickHandler>({ visibleLines, setVisibleLines }: ChartLegendProps) { + return { + onClick: (e: any) => { + const dataKey = e.dataKey as keyof T; + setVisibleLines((prev) => ({ + ...prev, + [dataKey]: !prev[dataKey], + })); + }, + formatter: (value: string, entry: any) => ( + + {value} + + ), + }; +} + +export const chartTooltipCursor = { + stroke: 'var(--color-text-secondary)', + strokeWidth: 1, + strokeDasharray: '4 4', +}; + +export const chartLegendStyle = { + wrapperStyle: { fontSize: '12px', paddingTop: '8px' }, + iconType: 'circle' as const, + iconSize: 8, +}; diff --git a/src/features/market-detail/components/charts/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index 0e2e8351..f7d80b0b 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -1,10 +1,8 @@ -/* eslint-disable react/no-unstable-nested-components */ - import { useState, useMemo } from 'react'; -import { Card, CardHeader, CardBody } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -import ButtonGroup from '@/components/ui/button-group'; import { Spinner } from '@/components/ui/spinner'; import { CHART_COLORS } from '@/constants/chartColors'; import { useAppSettings } from '@/stores/useAppSettings'; @@ -13,6 +11,15 @@ import { formatChartTime } from '@/utils/chart'; import { useMarketHistoricalData } from '@/hooks/useMarketHistoricalData'; import { useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; import { convertApyToApr } from '@/utils/rateMath'; +import { + TIMEFRAME_LABELS, + ChartGradients, + ChartTooltipContent, + RATE_CHART_GRADIENTS, + createLegendClickHandler, + chartTooltipCursor, + chartLegendStyle, +} from './chart-utils'; import type { Market } from '@/utils/types'; import type { TimeseriesDataPoint } from '@/utils/types'; @@ -23,14 +30,12 @@ type RateChartProps = { }; function RateChart({ marketId, chainId, market }: RateChartProps) { - // ✅ All hooks at top level - no conditional returns before hooks! const selectedTimeframe = useMarketDetailChartState((s) => s.selectedTimeframe); const selectedTimeRange = useMarketDetailChartState((s) => s.selectedTimeRange); const setTimeframe = useMarketDetailChartState((s) => s.setTimeframe); const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); - // Component fetches its own data (React Query caches by marketId + chainId + timeRange) const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); const [visibleLines, setVisibleLines] = useState({ @@ -48,10 +53,9 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { const { supplyApy, borrowApy, apyAtTarget } = historicalData.rates; return supplyApy.map((point: TimeseriesDataPoint, index: number) => { - // Convert values to APR if display mode is enabled const supplyVal = isAprDisplay ? convertApyToApr(point.y) : point.y; - const borrowVal = isAprDisplay ? convertApyToApr(borrowApy[index]?.y || 0) : borrowApy[index]?.y || 0; - const targetVal = isAprDisplay ? convertApyToApr(apyAtTarget[index]?.y || 0) : apyAtTarget[index]?.y || 0; + const borrowVal = isAprDisplay ? convertApyToApr(borrowApy[index]?.y ?? 0) : (borrowApy[index]?.y ?? 0); + const targetVal = isAprDisplay ? convertApyToApr(apyAtTarget[index]?.y ?? 0) : (apyAtTarget[index]?.y ?? 0); return { x: point.x, @@ -64,261 +68,195 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { const formatPercentage = (value: number) => `${(value * 100).toFixed(2)}%`; - const getCurrentApyValue = (type: 'supply' | 'borrow') => { - const apy = type === 'supply' ? market.state.supplyApy : market.state.borrowApy; - return isAprDisplay ? convertApyToApr(apy) : apy; - }; - - const getAverageApyValue = (type: 'supply' | 'borrow') => { - if (!historicalData?.rates) return 0; - const data = type === 'supply' ? historicalData.rates.supplyApy : historicalData.rates.borrowApy; - const avgApy = data.length > 0 ? data.reduce((sum: number, point: TimeseriesDataPoint) => sum + point.y, 0) / data.length : 0; - return isAprDisplay ? convertApyToApr(avgApy) : avgApy; - }; - - const getCurrentapyAtTargetValue = () => { - const apy = market.state.apyAtTarget; - return isAprDisplay ? convertApyToApr(apy) : apy; - }; + const toDisplayRate = (apy: number) => (isAprDisplay ? convertApyToApr(apy) : apy); - const getAverageapyAtTargetValue = () => { - if (!historicalData?.rates?.apyAtTarget || historicalData.rates.apyAtTarget.length === 0) return 0; - const avgApy = - historicalData.rates.apyAtTarget.reduce((sum: number, point: TimeseriesDataPoint) => sum + point.y, 0) / - historicalData.rates.apyAtTarget.length; - return isAprDisplay ? convertApyToApr(avgApy) : avgApy; + const getAverage = (data: TimeseriesDataPoint[] | undefined) => { + if (!data || data.length === 0) return 0; + return data.reduce((sum, point) => sum + point.y, 0) / data.length; }; - const getCurrentUtilizationRate = () => { - return market.state.utilization; - }; + const currentSupplyRate = toDisplayRate(market.state.supplyApy); + const currentBorrowRate = toDisplayRate(market.state.borrowApy); + const currentApyAtTarget = toDisplayRate(market.state.apyAtTarget); + const currentUtilization = market.state.utilization; - const getAverageUtilizationRate = () => { - if (!historicalData?.rates?.utilization || historicalData.rates.utilization.length === 0) return 0; - return ( - historicalData.rates.utilization.reduce((sum: number, point: TimeseriesDataPoint) => sum + point.y, 0) / - historicalData.rates.utilization.length - ); - }; + const avgSupplyRate = toDisplayRate(getAverage(historicalData?.rates?.supplyApy)); + const avgBorrowRate = toDisplayRate(getAverage(historicalData?.rates?.borrowApy)); + const avgApyAtTarget = toDisplayRate(getAverage(historicalData?.rates?.apyAtTarget)); + const avgUtilization = getAverage(historicalData?.rates?.utilization); - const timeframeOptions = [ - { key: '1d', label: '1D', value: '1d' }, - { key: '7d', label: '7D', value: '7d' }, - { key: '30d', label: '30D', value: '30d' }, - ]; + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); return ( - - - handleTimeframeChange(value as '1d' | '7d' | '30d')} - size="sm" - variant="default" - /> - - -
-
- {isLoading ? ( -
- -
- ) : ( - - - - - - - - - - - - - - - - - - formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} - /> - `${(value * 100).toFixed(2)}%`} /> - new Date(unixTime * 1000).toLocaleString()} - formatter={(value: number) => `${(value * 100).toFixed(2)}%`} - contentStyle={{ - backgroundColor: 'var(--color-background)', - }} - /> - { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} - /> - - - - - - )} + + {/* Header: Live Stats + Controls */} +
+ {/* Live Stats */} +
+
+

Supply {rateLabel}

+

{formatPercentage(currentSupplyRate)}

-
-
-

Current Rates

-
- -
-
- Supply APY: - {formatPercentage(getCurrentApyValue('supply'))} -
-
- Borrow APY: - {formatPercentage(getCurrentApyValue('borrow'))} -
-
- Rate at U Target: - {formatPercentage(getCurrentapyAtTargetValue())} -
-
+

Borrow {rateLabel}

+

{formatPercentage(currentBorrowRate)}

+
+
+

Rate at Target

+

{formatPercentage(currentApyAtTarget)}

+
+
+

Utilization

+
+ + {formatPercentage(currentUtilization)} +
+
+
+ + {/* Controls */} +
+ +
+
-
-

- Historical Averages ({selectedTimeframe}) -

- {isLoading ? ( -
- -
- ) : ( - <> -
- Utilization Rate: - {formatPercentage(getAverageUtilizationRate())} -
-
- Supply APY: - {formatPercentage(getAverageApyValue('supply'))} -
-
- Borrow APY: - {formatPercentage(getAverageApyValue('borrow'))} -
-
- Rate at U Target: - {formatPercentage(getAverageapyAtTargetValue())} -
- + {/* Chart Body - Full Width */} +
+ {isLoading ? ( +
+ +
+ ) : ( + + + + + formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + `${(value * 100).toFixed(2)}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={50} + /> + ( + )} -
+ /> + + + + + + + )} +
+ + {/* Footer: Historical Averages */} +
+

{TIMEFRAME_LABELS[selectedTimeframe]} Averages

+ {isLoading ? ( +
+ +
+ ) : ( +
+
+ Utilization + {formatPercentage(avgUtilization)} +
+
+ Supply {rateLabel} + {formatPercentage(avgSupplyRate)} +
+
+ Borrow {rateLabel} + {formatPercentage(avgBorrowRate)} +
+
+ Rate at Target + {formatPercentage(avgApyAtTarget)}
-
- + )} +
); } diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index cebfb37b..3169b4a1 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -1,12 +1,9 @@ -/* eslint-disable react/no-unstable-nested-components */ - import { useState, useMemo } from 'react'; -import { Card, CardHeader, CardBody } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Tooltip as HeroTooltip } from '@/components/ui/tooltip'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { formatUnits } from 'viem'; -import { HiOutlineInformationCircle } from 'react-icons/hi2'; -import ButtonGroup from '@/components/ui/button-group'; import { Spinner } from '@/components/ui/spinner'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { CHART_COLORS } from '@/constants/chartColors'; @@ -14,6 +11,15 @@ import { formatReadable } from '@/utils/balance'; import { formatChartTime } from '@/utils/chart'; import { useMarketHistoricalData } from '@/hooks/useMarketHistoricalData'; import { useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; +import { + TIMEFRAME_LABELS, + ChartGradients, + ChartTooltipContent, + VOLUME_CHART_GRADIENTS, + createLegendClickHandler, + chartTooltipCursor, + chartLegendStyle, +} from './chart-utils'; import type { Market } from '@/utils/types'; import type { TimeseriesDataPoint } from '@/utils/types'; @@ -24,14 +30,12 @@ type VolumeChartProps = { }; function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { - // ✅ All hooks at top level - no conditional returns before hooks! const selectedTimeframe = useMarketDetailChartState((s) => s.selectedTimeframe); const selectedTimeRange = useMarketDetailChartState((s) => s.selectedTimeRange); const volumeView = useMarketDetailChartState((s) => s.volumeView); const setTimeframe = useMarketDetailChartState((s) => s.setTimeframe); const setVolumeView = useMarketDetailChartState((s) => s.setVolumeView); - // Component fetches its own data (React Query caches by marketId + chainId + timeRange) const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); const [visibleLines, setVisibleLines] = useState({ @@ -58,21 +62,17 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { const borrowData = volumeView === 'USD' ? historicalData.volumes.borrowAssetsUsd : historicalData.volumes.borrowAssets; const liquidityData = volumeView === 'USD' ? historicalData.volumes.liquidityAssetsUsd : historicalData.volumes.liquidityAssets; - // Process all data in a single loop return supplyData .map((point: TimeseriesDataPoint, index: number) => { - // Get corresponding points from other series const borrowPoint: TimeseriesDataPoint | undefined = borrowData[index]; const liquidityPoint: TimeseriesDataPoint | undefined = liquidityData[index]; - // Convert values based on view type const supplyValue = volumeView === 'USD' ? point.y : Number(formatUnits(BigInt(point.y), market.loanAsset.decimals)); const borrowValue = volumeView === 'USD' ? borrowPoint?.y || 0 : Number(formatUnits(BigInt(borrowPoint?.y || 0), market.loanAsset.decimals)); const liquidityValue = volumeView === 'USD' ? liquidityPoint?.y || 0 : Number(formatUnits(BigInt(liquidityPoint?.y || 0), market.loanAsset.decimals)); - // Check if any timestamps has USD value exceeds 100B if (historicalData.volumes.supplyAssetsUsd[index].y >= 100_000_000_000) { return null; } @@ -92,313 +92,264 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { return volumeView === 'USD' ? `$${formattedValue}` : `${formattedValue} ${market.loanAsset.symbol}`; }; - const getCurrentVolumeStats = (type: 'supply' | 'borrow' | 'liquidity') => { - const data = volumeView === 'USD' ? historicalData?.volumes[`${type}AssetsUsd`] : historicalData?.volumes[`${type}Assets`]; - if (!data || data.length === 0) return { current: 0, netChange: 0, netChangePercentage: 0 }; + const toValue = (raw: number | bigint) => + volumeView === 'USD' ? Number(raw) : Number(formatUnits(BigInt(raw), market.loanAsset.decimals)); - const current = - volumeView === 'USD' - ? (data.at(-1) as TimeseriesDataPoint).y - : Number(formatUnits(BigInt((data.at(-1) as TimeseriesDataPoint).y), market.loanAsset.decimals)); - const start = volumeView === 'USD' ? data[0].y : Number(formatUnits(BigInt(data[0].y), market.loanAsset.decimals)); - const netChange = current - start; - const netChangePercentage = start !== 0 ? (netChange / start) * 100 : 0; + const getVolumeStats = (type: 'supply' | 'borrow' | 'liquidity') => { + const data = volumeView === 'USD' ? historicalData?.volumes[`${type}AssetsUsd`] : historicalData?.volumes[`${type}Assets`]; + if (!data || data.length === 0) return { current: 0, netChangePercentage: 0, average: 0 }; - return { current, netChange, netChangePercentage }; - }; + const current = toValue((data.at(-1) as TimeseriesDataPoint).y); + const start = toValue(data[0].y); + const netChangePercentage = start !== 0 ? ((current - start) / start) * 100 : 0; + const average = data.reduce((acc: number, point: TimeseriesDataPoint) => acc + toValue(point.y), 0) / data.length; - const getAverageVolumeStats = (type: 'supply' | 'borrow' | 'liquidity') => { - const data = volumeView === 'USD' ? historicalData?.volumes[`${type}AssetsUsd`] : historicalData?.volumes[`${type}Assets`]; - if (!data || data.length === 0) return 0; - const sum = data.reduce( - (acc: number, point: TimeseriesDataPoint) => - acc + Number(volumeView === 'USD' ? point.y : formatUnits(BigInt(point.y), market.loanAsset.decimals)), - 0, - ); - return sum / data.length; + return { current, netChangePercentage, average }; }; - const volumeViewOptions = [ - { key: 'USD', label: 'USD', value: 'USD' }, - { key: 'Asset', label: market.loanAsset.symbol, value: 'Asset' }, - ]; - - const timeframeOptions = [ - { key: '1d', label: '1D', value: '1d' }, - { key: '7d', label: '7D', value: '7d' }, - { key: '30d', label: '30D', value: '30d' }, - ]; + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); - // This is only for adaptive curve const targetUtilizationData = useMemo(() => { const supply = market.state.supplyAssets ? BigInt(market.state.supplyAssets) : 0n; const borrow = market.state.borrowAssets ? BigInt(market.state.borrowAssets) : 0n; - // Calculate deltas to reach 90% target utilization - const targetBorrow = (supply * 9n) / 10n; // B_target = S * 0.9 + const targetBorrow = (supply * 9n) / 10n; const borrowDelta = targetBorrow - borrow; - const targetSupply = (borrow * 10n) / 9n; // S_target = B / 0.9 + const targetSupply = (borrow * 10n) / 9n; const supplyDelta = targetSupply - supply; - return { - borrowDelta, - supplyDelta, - }; + return { borrowDelta, supplyDelta }; }, [market.state.supplyAssets, market.state.borrowAssets]); + const supplyStats = getVolumeStats('supply'); + const borrowStats = getVolumeStats('borrow'); + const liquidityStats = getVolumeStats('liquidity'); + return ( - - - setVolumeView(value as 'USD' | 'Asset')} - size="sm" - variant="default" - /> - handleTimeframeChange(value as '1d' | '7d' | '30d')} - size="sm" - variant="default" - /> - - -
-
- {isLoading ? ( -
- -
- ) : ( - - - - - - - - - - - - - - - - - - formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} - /> - - new Date(unixTime * 1000).toLocaleString()} - formatter={(value: number, name: string) => [formatValue(value), name]} - contentStyle={{ - backgroundColor: 'var(--color-background)', - }} - /> - { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} - /> - + {/* Header: Live Stats + Controls */} +
+ {/* Live Stats */} +
+
+

Supply

+
+ {formatValue(supplyStats.current)} + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {supplyStats.netChangePercentage >= 0 ? '+' : ''} + {supplyStats.netChangePercentage.toFixed(2)}% + +
+
+
+

Borrow

+
+ {formatValue(borrowStats.current)} + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {borrowStats.netChangePercentage >= 0 ? '+' : ''} + {borrowStats.netChangePercentage.toFixed(2)}% + +
+
+
+

Liquidity

+
+ {formatValue(liquidityStats.current)} + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {liquidityStats.netChangePercentage >= 0 ? '+' : ''} + {liquidityStats.netChangePercentage.toFixed(2)}% + +
+
+
+ + {/* Controls */} +
+ + +
+
+ + {/* Chart Body - Full Width */} +
+ {isLoading ? ( +
+ +
+ ) : ( + + + + + formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + ( + - + + + + + + + )} +
+ + {/* Footer: IRM Targets + Historical Averages */} +
+ {/* IRM Targets */} +
+

IRM Rebalancing Targets

+
+
+ - + Supply Δ + + + {formatValue(Number(formatUnits(targetUtilizationData.supplyDelta, market.loanAsset.decimals)))} + +
+
+ - - - )} + } + > + Borrow Δ + + + {formatValue(Number(formatUnits(targetUtilizationData.borrowDelta, market.loanAsset.decimals)))} + +
-
-
-
-

Current Volumes

- {['supply', 'borrow', 'liquidity'].map((type) => { - const stats = getCurrentVolumeStats(type as 'supply' | 'borrow' | 'liquidity'); - return ( -
- {type}: - - {formatValue(stats.current)} - 0 ? 'ml-2 text-green-500' : 'ml-2 text-red-500'}> - ({stats.netChangePercentage > 0 ? '+' : ''} - {stats.netChangePercentage.toFixed(2)}%) - - -
- ); - })} - - {/* Delta to target Utilization */} -
-

IRM Targets

-
- - Supply Δ: - - } - > - - - - - - - {formatValue(Number(formatUnits(targetUtilizationData.supplyDelta, market.loanAsset.decimals)))} - -
+
-
- - Borrow Δ: - - } - > - - - - - - - {formatValue(Number(formatUnits(targetUtilizationData.borrowDelta, market.loanAsset.decimals)))} - -
-
+ {/* Historical Averages */} +
+

{TIMEFRAME_LABELS[selectedTimeframe]} Averages

+ {isLoading ? ( +
+ +
+ ) : ( +
+
+ Supply + {formatValue(supplyStats.average)}
- -
-

- Historical Averages ({selectedTimeframe}) -

- {isLoading ? ( -
- -
- ) : ( - ['supply', 'borrow', 'liquidity'].map((type) => ( -
- {type}: - - {formatValue(getAverageVolumeStats(type as 'supply' | 'borrow' | 'liquidity'))} - -
- )) - )} +
+ Borrow + {formatValue(borrowStats.average)} +
+
+ Liquidity + {formatValue(liquidityStats.average)}
-
+ )}
- +
); } diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx new file mode 100644 index 00000000..847763ea --- /dev/null +++ b/src/features/market-detail/components/market-header.tsx @@ -0,0 +1,458 @@ +'use client'; + +import Image from 'next/image'; +import { formatUnits } from 'viem'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { GrStatusGood } from 'react-icons/gr'; +import { IoWarningOutline, IoEllipsisVertical } from 'react-icons/io5'; +import { MdError } from 'react-icons/md'; +import { BsArrowUpCircle, BsArrowDownLeftCircle } from 'react-icons/bs'; +import { FiExternalLink } from 'react-icons/fi'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { Tooltip } from '@/components/ui/tooltip'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { AddressIdentity } from '@/components/shared/address-identity'; +import { CampaignBadge } from '@/features/market-detail/components/campaign-badge'; +import { PositionPill } from '@/features/market-detail/components/position-pill'; +import { OracleTypeInfo } from '@/features/markets/components/oracle/MarketOracle/OracleTypeInfo'; +import { useRateLabel } from '@/hooks/useRateLabel'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { convertApyToApr } from '@/utils/rateMath'; +import { getIRMTitle } from '@/utils/morpho'; +import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { getMarketURL } from '@/utils/external'; +import type { Market, MarketPosition, WarningWithDetail } from '@/utils/types'; +import { WarningCategory } from '@/utils/types'; +import { getRiskLevel, countWarningsByLevel, type RiskLevel } from '@/utils/warnings'; + +// Reusable component for rendering a block of warnings with consistent styling +type WarningBlockProps = { + warnings: WarningWithDetail[]; + riskLevel: RiskLevel; +}; + +function WarningBlock({ warnings, riskLevel }: WarningBlockProps): React.ReactNode { + if (warnings.length === 0) return null; + + const isAlert = riskLevel === 'red'; + const containerClass = isAlert + ? 'border-red-200 bg-red-50 dark:border-red-400/20 dark:bg-red-400/10' + : 'border-yellow-200 bg-yellow-50 dark:border-yellow-400/20 dark:bg-yellow-400/10'; + + return ( +
+
+ {isAlert ? ( + + ) : ( + + )} +
+ {warnings.map((w) => ( +

+ {w.description} +

+ ))} +
+
+
+ ); +} + +// Reusable badge component for warning/alert counts +type StatusBadgeProps = { + variant: 'success' | 'warning' | 'alert'; + count?: number; + label: string; +}; + +function StatusBadge({ variant, count, label }: StatusBadgeProps): React.ReactNode { + const styles = { + success: 'bg-green-100 text-green-800 dark:bg-green-400/10 dark:text-green-300', + warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-300', + alert: 'bg-red-100 text-red-800 dark:bg-red-400/10 dark:text-red-300', + }; + + const icons = { + success: , + warning: , + alert: , + }; + + const displayLabel = count !== undefined ? `${count} ${label}${count > 1 ? 's' : ''}` : label; + + return ( + + {icons[variant]} + {displayLabel} + + ); +} + +// Risk indicator icon component +function RiskIcon({ level }: { level: RiskLevel }): React.ReactNode { + switch (level) { + case 'green': + return ; + case 'yellow': + return ; + case 'red': + return ; + default: + return null; + } +} + +type MarketHeaderProps = { + market: Market; + marketId: string; + network: SupportedNetworks; + userPosition: MarketPosition | null; + oraclePrice: string; + allWarnings: WarningWithDetail[]; + onSupplyClick: () => void; + onBorrowClick: () => void; +}; + +export function MarketHeader({ + market, + marketId, + network, + userPosition, + oraclePrice, + allWarnings, + onSupplyClick, + onBorrowClick, +}: MarketHeaderProps) { + const { short: rateLabel } = useRateLabel(); + const { isAprDisplay } = useAppSettings(); + const networkImg = getNetworkImg(network); + + const formatRate = (rate: number) => { + const displayRate = isAprDisplay ? convertApyToApr(rate) : rate; + return `${(displayRate * 100).toFixed(2)}%`; + }; + + const formattedLltv = `${formatUnits(BigInt(market.lltv), 16)}%`; + + const campaignBadgeProps = { + marketId, + loanTokenAddress: market.loanAsset.address, + chainId: market.morphoBlue.chain.id, + whitelisted: market.whitelisted, + }; + + // Filter warnings by category + const assetWarnings = allWarnings.filter((w) => w.category === WarningCategory.asset); + const oracleWarnings = allWarnings.filter((w) => w.category === WarningCategory.oracle); + const globalWarnings = allWarnings.filter((w) => w.category === WarningCategory.debt || w.category === WarningCategory.general); + + // Compute risk levels for each category + const assetRiskLevel = getRiskLevel(assetWarnings); + const oracleRiskLevel = getRiskLevel(oracleWarnings); + const globalRiskLevel = getRiskLevel(globalWarnings); + const { alertCount, warningCount } = countWarningsByLevel(allWarnings); + + // Render summary badges based on warning counts + const renderSummaryBadges = (): React.ReactNode => { + if (allWarnings.length === 0) { + return ( + + ); + } + + if (alertCount > 0 && warningCount > 0) { + return ( + + + + + ); + } + + if (alertCount > 0) { + return ( + + ); + } + + return ( + + ); + }; + + return ( +
+ {/* Main Header */} +
+
+ {/* LEFT: Market Identity */} +
+ {/* Overlapping token icons */} +
+ + +
+ +
+
+ {market.loanAsset.symbol}/{market.collateralAsset.symbol} +
+
+ {networkImg && ( +
+ {network.toString()} + {getNetworkName(network)} +
+ )} + · + LLTV {formattedLltv} + · + {getIRMTitle(market.irmAddress)} +
+
+
+ + {/* RIGHT: Stats + Actions */} +
+ {/* Key Stats - Hidden on small screens */} +
+
+

Supply {rateLabel}

+
+

{formatRate(market.state.supplyApy)}

+ +
+
+
+

Borrow {rateLabel}

+
+

{formatRate(market.state.borrowApy)}

+ +
+
+
+ + } + > +
+

Oracle

+

{Number(oraclePrice).toFixed(2)}

+
+
+
+
+ + {/* Position Pill + Actions Dropdown */} +
+ {userPosition && ( + + )} + + + + + + } + > + Supply + + } + > + Borrow + + window.open(getMarketURL(marketId, network), '_blank')} + startContent={} + > + View on Morpho + + + +
+
+
+ + {/* Mobile Stats Row - Visible only on small screens */} +
+
+

Supply {rateLabel}

+
+

{formatRate(market.state.supplyApy)}

+ +
+
+
+

Borrow {rateLabel}

+
+

{formatRate(market.state.borrowApy)}

+ +
+
+
+

Oracle

+

{Number(oraclePrice).toFixed(2)}

+
+
+ + {/* Advanced Details - Expandable */} +
+ + + Advanced Details + {renderSummaryBadges()} + + +
+ {/* Global Warnings (debt + general) at top */} + + + {/* Two-column grid */} +
+ {/* LEFT: Market Configuration */} +
+
+

Market Configuration

+ +
+ +
+
+ Loan: + +
+
+ Collateral: + +
+
+ IRM: + +
+
+ + {/* Asset warnings */} + +
+ + {/* RIGHT: Oracle */} +
+
+

Oracle

+ +
+ + + + {/* Oracle warnings */} + +
+
+
+
+
+
+ ); +} diff --git a/src/features/market-detail/components/market-warning-banner.tsx b/src/features/market-detail/components/market-warning-banner.tsx deleted file mode 100644 index 259b91ea..00000000 --- a/src/features/market-detail/components/market-warning-banner.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { motion } from 'framer-motion'; -import { MdError } from 'react-icons/md'; -import { IoWarningOutline } from 'react-icons/io5'; -import type { WarningWithDetail } from '@/utils/types'; - -type MarketWarningBannerProps = { - warnings: WarningWithDetail[]; -}; - -export function MarketWarningBanner({ warnings }: MarketWarningBannerProps) { - if (warnings.length === 0) return null; - - const hasAlert = warnings.some((w) => w.level === 'alert'); - const Icon = hasAlert ? MdError : IoWarningOutline; - - const colorClasses = hasAlert ? 'border-red-500/20 bg-red-500/10 text-red-500' : 'border-yellow-500/20 bg-yellow-500/10 text-yellow-500'; - - return ( - -
- -
- {warnings.map((warning) => ( -

{warning.description}

- ))} -
-
-
- ); -} diff --git a/src/features/market-detail/components/position-pill.tsx b/src/features/market-detail/components/position-pill.tsx new file mode 100644 index 00000000..452dedaa --- /dev/null +++ b/src/features/market-detail/components/position-pill.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { formatUnits } from 'viem'; +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { formatReadable } from '@/utils/balance'; +import type { MarketPosition } from '@/utils/types'; + +type PositionRowProps = { + tokenAddress: string; + chainId: number; + symbol: string; + label: string; + amount: number; + textColor?: string; +}; + +function PositionRow({ tokenAddress, chainId, symbol, label, amount, textColor }: PositionRowProps) { + if (amount <= 0) return null; + return ( +
+
+ + {label} +
+ + {formatReadable(amount)} {symbol} + +
+ ); +} + +type PositionPillProps = { + position: MarketPosition; + onSupplyClick?: () => void; + onBorrowClick?: () => void; +}; + +export function PositionPill({ position, onSupplyClick, onBorrowClick }: PositionPillProps) { + const { market, state } = position; + + const supplyAmount = Number(formatUnits(BigInt(state.supplyAssets), market.loanAsset.decimals)); + const borrowAmount = Number(formatUnits(BigInt(state.borrowAssets), market.loanAsset.decimals)); + const collateralAmount = Number(formatUnits(BigInt(state.collateral), market.collateralAsset.decimals)); + + // Check if user has any position + const hasPosition = supplyAmount > 0 || borrowAmount > 0 || collateralAmount > 0; + + if (!hasPosition) { + return null; + } + + return ( + + + + + +
+

Your Position

+ + + + + + {/* Action buttons */} + {(onSupplyClick ?? onBorrowClick) && ( +
+ {onSupplyClick && ( + + )} + {onBorrowClick && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 8233594d..31ff4d15 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -3,45 +3,30 @@ 'use client'; import { useState, useCallback, useMemo } from 'react'; -import { Card, CardHeader, CardBody } from '@/components/ui/card'; -import { ExternalLinkIcon } from '@radix-ui/react-icons'; -import Image from 'next/image'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { formatUnits, parseUnits } from 'viem'; +import { parseUnits, formatUnits } from 'viem'; import { useConnection } from 'wagmi'; import { BorrowModal } from '@/modals/borrow/borrow-modal'; -import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Spinner } from '@/components/ui/spinner'; import Header from '@/components/layout/header/Header'; -import { OracleTypeInfo } from '@/features/markets/components/oracle'; -import { TokenIcon } from '@/components/shared/token-icon'; import { useModal } from '@/hooks/useModal'; import { useMarketData } from '@/hooks/useMarketData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import { useTransactionFilters } from '@/stores/useTransactionFilters'; import useUserPosition from '@/hooks/useUserPosition'; -import MORPHO_LOGO from '@/imgs/tokens/morpho.svg'; -import { getExplorerURL, getMarketURL } from '@/utils/external'; -import { getIRMTitle } from '@/utils/morpho'; -import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; -import { getTruncatedAssetName } from '@/utils/oracle'; +import type { SupportedNetworks } from '@/utils/networks'; import { BorrowersTable } from '@/features/market-detail/components/borrowers-table'; import { BorrowsTable } from '@/features/market-detail/components/borrows-table'; import BorrowerFiltersModal from '@/features/market-detail/components/filters/borrower-filters-modal'; -import { CampaignBadge } from '@/features/market-detail/components/campaign-badge'; import { LiquidationsTable } from '@/features/market-detail/components/liquidations-table'; -import { PositionStats } from '@/features/market-detail/components/position-stats'; import { SuppliesTable } from '@/features/market-detail/components/supplies-table'; import { SuppliersTable } from '@/features/market-detail/components/suppliers-table'; import SupplierFiltersModal from '@/features/market-detail/components/filters/supplier-filters-modal'; import TransactionFiltersModal from '@/features/market-detail/components/filters/transaction-filters-modal'; import { useMarketWarnings } from '@/hooks/useMarketWarnings'; -import { WarningCategory } from '@/utils/types'; -import { CardWarningIndicator } from './components/card-warning-indicator'; +import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; -import { MarketWarningBanner } from './components/market-warning-banner'; import VolumeChart from './components/charts/volume-chart'; function MarketContent() { @@ -50,7 +35,6 @@ function MarketContent() { // 2. Network setup const network = Number(chainId as string) as SupportedNetworks; - const networkImg = getNetworkImg(network); // 3. Consolidated state const { open: openModal } = useModal(); @@ -83,92 +67,79 @@ function MarketContent() { const { address } = useConnection(); - const { - position: userPosition, - loading: positionLoading, - refetch: refetchUserPosition, - } = useUserPosition(address, network, marketId as string); + const { position: userPosition, refetch: refetchUserPosition } = useUserPosition(address, network, marketId as string); // Get all warnings for this market (hook handles undefined market) const allWarnings = useMarketWarnings(market); // 6. All memoized values and callbacks - const formattedOraclePrice = useMemo(() => { - if (!market) return '0'; - const adjusted = (oraclePrice * BigInt(10 ** market.collateralAsset.decimals)) / BigInt(10 ** market.loanAsset.decimals); - return formatUnits(adjusted, 36); - }, [oraclePrice, market]); - // convert to token amounts - const scaledMinSupplyAmount = useMemo(() => { - if (!market || !minSupplyAmount || minSupplyAmount === '0' || minSupplyAmount === '') { - return '0'; - } + // Helper to scale user input to token amount + const scaleToTokenAmount = (value: string, decimals: number): string => { + if (!value || value === '0' || value === '') return '0'; try { - return parseUnits(minSupplyAmount, market.loanAsset.decimals).toString(); + return parseUnits(value, decimals).toString(); } catch { return '0'; } - }, [minSupplyAmount, market]); + }; - const scaledMinBorrowAmount = useMemo(() => { - if (!market || !minBorrowAmount || minBorrowAmount === '0' || minBorrowAmount === '') { - return '0'; - } + // Helper to convert asset amount to shares: (amount × totalShares) / totalAssets + const convertAssetToShares = (amount: string, totalAssets: bigint, totalShares: bigint, decimals: number): string => { + if (!amount || amount === '0' || amount === '' || totalAssets === 0n) return '0'; try { - return parseUnits(minBorrowAmount, market.loanAsset.decimals).toString(); + const assetAmount = parseUnits(amount, decimals); + return ((assetAmount * totalShares) / totalAssets).toString(); } catch { return '0'; } - }, [minBorrowAmount, market]); + }; - // Convert user-specified asset amount to shares for filtering suppliers - // Formula: effectiveMinShares = (minAssetAmount × totalSupplyShares) / totalSupplyAssets - const scaledMinSupplierShares = useMemo(() => { - if (!market || !minSupplierShares || minSupplierShares === '0' || minSupplierShares === '') { - return '0'; - } - try { - const minAssetAmount = parseUnits(minSupplierShares, market.loanAsset.decimals); - const totalSupplyAssets = BigInt(market.state.supplyAssets); - const totalSupplyShares = BigInt(market.state.supplyShares); - - // If no supply yet, return 0 - if (totalSupplyAssets === 0n) { - return '0'; - } - - // Convert asset amount to shares - const effectiveMinShares = (minAssetAmount * totalSupplyShares) / totalSupplyAssets; - return effectiveMinShares.toString(); - } catch { - return '0'; - } - }, [minSupplierShares, market]); + // Oracle price scaled for display (36 decimals is the Morpho oracle price scale) + const ORACLE_PRICE_SCALE = 36; + const formattedOraclePrice = useMemo(() => { + if (!market) return '0'; + const adjusted = (oraclePrice * BigInt(10 ** market.collateralAsset.decimals)) / BigInt(10 ** market.loanAsset.decimals); + return formatUnits(adjusted, ORACLE_PRICE_SCALE); + }, [oraclePrice, market]); - // Convert user-specified asset amount to shares for filtering borrowers - // Formula: effectiveMinShares = (minAssetAmount × totalBorrowShares) / totalBorrowAssets - const scaledMinBorrowerShares = useMemo(() => { - if (!market || !minBorrowerShares || minBorrowerShares === '0' || minBorrowerShares === '') { - return '0'; - } - try { - const minAssetAmount = parseUnits(minBorrowerShares, market.loanAsset.decimals); - const totalBorrowAssets = BigInt(market.state.borrowAssets); - const totalBorrowShares = BigInt(market.state.borrowShares); - - // If no borrows yet, return 0 - if (totalBorrowAssets === 0n) { - return '0'; - } - - // Convert asset amount to shares - const effectiveMinShares = (minAssetAmount * totalBorrowShares) / totalBorrowAssets; - return effectiveMinShares.toString(); - } catch { - return '0'; - } - }, [minBorrowerShares, market]); + // Convert filter amounts to token amounts + const scaledMinSupplyAmount = useMemo( + () => (market ? scaleToTokenAmount(minSupplyAmount, market.loanAsset.decimals) : '0'), + [minSupplyAmount, market], + ); + + const scaledMinBorrowAmount = useMemo( + () => (market ? scaleToTokenAmount(minBorrowAmount, market.loanAsset.decimals) : '0'), + [minBorrowAmount, market], + ); + + // Convert user-specified asset amounts to shares for filtering suppliers/borrowers + const scaledMinSupplierShares = useMemo( + () => + market + ? convertAssetToShares( + minSupplierShares, + BigInt(market.state.supplyAssets), + BigInt(market.state.supplyShares), + market.loanAsset.decimals, + ) + : '0', + [minSupplierShares, market], + ); + + const scaledMinBorrowerShares = useMemo( + () => + market + ? convertAssetToShares( + minBorrowerShares, + BigInt(market.state.borrowAssets), + BigInt(market.state.borrowShares), + market.loanAsset.decimals, + ) + : '0', + [minBorrowerShares, market], + ); // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { @@ -215,67 +186,30 @@ function MarketContent() { ); } - // 8. Derived values that depend on market data - const cardStyle = 'bg-surface rounded shadow-sm p-4'; + // Handlers for supply/borrow actions + const handleSupplyClick = () => { + openModal('supply', { market, position: userPosition, isMarketPage: true, refetch: handleRefreshAllSync }); + }; - // 9. Warning filtering by category - const pageWarnings = allWarnings.filter((w) => w.category === WarningCategory.debt || w.category === WarningCategory.general); - const assetWarnings = allWarnings.filter((w) => w.category === WarningCategory.asset); - const oracleWarnings = allWarnings.filter((w) => w.category === WarningCategory.oracle); + const handleBorrowClick = () => { + setShowBorrowModal(true); + }; return ( <>
- {/* Market title and actions */} -
-
-

- {market.loanAsset.symbol}/{market.collateralAsset.symbol} Market -

- -
- -
- - - -
-
- - {/* Page-level warnings (debt + general) */} - + {/* Unified Market Header */} + {showBorrowModal && ( )} -
- - - Basic Info -
- - {networkImg && ( - {network.toString()} - )} - {getNetworkName(network)} -
-
- -
-
- Loan Asset: -
- - - {getTruncatedAssetName(market.loanAsset.symbol)} - -
-
-
- Collateral Asset: -
- - - {getTruncatedAssetName(market.collateralAsset.symbol)} - -
-
-
- IRM: - - {getIRMTitle(market.irmAddress)} - -
-
- LLTV: - {formatUnits(BigInt(market.lltv), 16)}% -
-
-
-
- - - - Oracle Info -
- - - - -
-
- -
-
- Live Price: - - {Number(formattedOraclePrice).toFixed(4)} {market.loanAsset.symbol} - -
- -
-
-
- - -
- {/* Tabs Section */} -

Volume

-

Rates

- +
+ +
diff --git a/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx b/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx index f4644d34..b8b35f17 100644 --- a/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx +++ b/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx @@ -115,7 +115,7 @@ export function FeedEntry({ feed, chainId }: FeedEntryProps): JSX.Element | null return ( -
+
{showAssetPair ? (
{baseAsset} diff --git a/src/features/markets/components/oracle/MarketOracle/MarketOracleFeedInfo.tsx b/src/features/markets/components/oracle/MarketOracle/MarketOracleFeedInfo.tsx index 735391f1..e70eec58 100644 --- a/src/features/markets/components/oracle/MarketOracle/MarketOracleFeedInfo.tsx +++ b/src/features/markets/components/oracle/MarketOracle/MarketOracleFeedInfo.tsx @@ -9,14 +9,6 @@ type MarketOracleFeedInfoProps = { chainId: number; }; -function EmptyFeedSlot() { - return ( -
- -- -
- ); -} - export function MarketOracleFeedInfo({ baseFeedOne, baseFeedTwo, @@ -30,26 +22,24 @@ export function MarketOracleFeedInfo({ return
No feed routes available
; } - const renderFeed = (feed: OracleFeed | null | undefined) => - feed ? ( -
- -
- ) : ( - - ); - return (
{(baseFeedOne || baseFeedTwo) && (
Base: -
-
{renderFeed(baseFeedOne)}
-
{renderFeed(baseFeedTwo)}
+
+ {baseFeedOne && ( + + )} + {baseFeedTwo && ( + + )}
)} @@ -57,9 +47,19 @@ export function MarketOracleFeedInfo({ {(quoteFeedOne || quoteFeedTwo) && (
Quote: -
-
{renderFeed(quoteFeedOne)}
-
{renderFeed(quoteFeedTwo)}
+
+ {quoteFeedOne && ( + + )} + {quoteFeedTwo && ( + + )}
)} diff --git a/src/features/markets/components/oracle/MarketOracle/OracleTypeInfo.tsx b/src/features/markets/components/oracle/MarketOracle/OracleTypeInfo.tsx index 748fabec..39ec2264 100644 --- a/src/features/markets/components/oracle/MarketOracle/OracleTypeInfo.tsx +++ b/src/features/markets/components/oracle/MarketOracle/OracleTypeInfo.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import { AddressIdentity } from '@/components/shared/address-identity'; import { MarketOracleFeedInfo } from '@/features/markets/components/oracle'; import { getExplorerURL } from '@/utils/external'; import { getOracleType, getOracleTypeDescription, OracleType } from '@/utils/oracle'; @@ -11,30 +12,42 @@ type OracleTypeInfoProps = { chainId: number; showLink?: boolean; showCustom?: boolean; + useBadge?: boolean; }; -export function OracleTypeInfo({ oracleData, oracleAddress, chainId, showLink, showCustom }: OracleTypeInfoProps) { +export function OracleTypeInfo({ oracleData, oracleAddress, chainId, showLink, showCustom, useBadge }: OracleTypeInfoProps) { const oracleType = getOracleType(oracleData, oracleAddress, chainId); const typeDescription = getOracleTypeDescription(oracleType); return ( <> -
- Oracle Type: - {showLink ? ( - - {typeDescription} - - - ) : ( - {typeDescription} - )} -
+ {useBadge ? ( +
+ Type: + +
+ ) : ( +
+ Oracle Type: + {showLink ? ( + + {typeDescription} + + + ) : ( + {typeDescription} + )} +
+ )} {oracleType === OracleType.Standard ? ( w === UNRECOGNIZED_FEEDS) === undefined) { + if (!result.includes(UNRECOGNIZED_FEEDS)) { result.push(UNKNOWN_FEED_FOR_PAIR_MATCHING); } } else if (!feedsPathResult.isValid) { - // Create a dynamic warning with the specific error message - const incompatibleFeedsWarning: WarningWithDetail = { + result.push({ ...INCOMPATIBLE_ORACLE_FEEDS, description: feedsPathResult.missingPath ?? INCOMPATIBLE_ORACLE_FEEDS.description, - }; - result.push(incompatibleFeedsWarning); + }); } } } @@ -231,3 +229,27 @@ export const getMarketWarningsWithDetail = (market: Market, considerWhitelist = return result; }; + +// Risk level type for UI components +export type RiskLevel = 'green' | 'yellow' | 'red'; + +/** + * Determine risk level for a set of warnings + * - green: no warnings + * - yellow: has warnings but no alerts + * - red: has at least one alert-level warning + */ +export const getRiskLevel = (warnings: WarningWithDetail[]): RiskLevel => { + if (warnings.length === 0) return 'green'; + if (warnings.some((w) => w.level === 'alert')) return 'red'; + return 'yellow'; +}; + +/** + * Count warnings by level + */ +export const countWarningsByLevel = (warnings: WarningWithDetail[]) => { + const alertCount = warnings.filter((w) => w.level === 'alert').length; + const warningCount = warnings.filter((w) => w.level === 'warning').length; + return { alertCount, warningCount }; +};