From 852fa3e21c3a87a767710ee8f1dabf2a6286503c Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 16:20:06 +0800 Subject: [PATCH 1/7] feat: new graph --- src/components/ui/button-group.tsx | 4 +- .../components/charts/rate-chart.tsx | 105 ++++++++++++------ .../components/charts/volume-chart.tsx | 93 +++++++++++----- 3 files changed, 144 insertions(+), 58 deletions(-) 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 (
handleTimeframeChange(value as '1d' | '7d' | '30d')} size="sm" variant="default" + equalWidth /> @@ -131,7 +132,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { height={400} id="rate-chart" > - + - + 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} /> - `${(value * 100).toFixed(2)}%`} /> new Date(unixTime * 1000).toLocaleString()} - formatter={(value: number) => `${(value * 100).toFixed(2)}%`} - contentStyle={{ - backgroundColor: 'var(--color-background)', + cursor={{ stroke: 'var(--color-text-secondary)', strokeWidth: 1, strokeDasharray: '4 4' }} + content={({ active, payload, label }) => { + if (!active || !payload) return null; + return ( +
+

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

+
+ {payload.map((entry: any) => ( +
+
+ + {entry.name} +
+ {`${(entry.value * 100).toFixed(2)}%`} +
+ ))} +
+
+ ); }} /> { const dataKey = e.dataKey as keyof typeof visibleLines; setVisibleLines((prev) => ({ @@ -211,8 +251,11 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { }} formatter={(value, entry) => ( {value} @@ -266,7 +309,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { color="primary" showValueLabel classNames={{ - value: 'font-zen text-sm', + value: 'font-zen text-sm tabular-nums', base: 'my-2', label: 'text-base', }} @@ -274,15 +317,15 @@ function RateChart({ marketId, chainId, market }: RateChartProps) {
Supply APY: - {formatPercentage(getCurrentApyValue('supply'))} + {formatPercentage(getCurrentApyValue('supply'))}
Borrow APY: - {formatPercentage(getCurrentApyValue('borrow'))} + {formatPercentage(getCurrentApyValue('borrow'))}
Rate at U Target: - {formatPercentage(getCurrentapyAtTargetValue())} + {formatPercentage(getCurrentapyAtTargetValue())}
@@ -298,19 +341,19 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { <>
Utilization Rate: - {formatPercentage(getAverageUtilizationRate())} + {formatPercentage(getAverageUtilizationRate())}
Supply APY: - {formatPercentage(getAverageApyValue('supply'))} + {formatPercentage(getAverageApyValue('supply'))}
Borrow APY: - {formatPercentage(getAverageApyValue('borrow'))} + {formatPercentage(getAverageApyValue('borrow'))}
Rate at U Target: - {formatPercentage(getAverageapyAtTargetValue())} + {formatPercentage(getAverageapyAtTargetValue())}
)} diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index cebfb37b..2a08ce78 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -163,6 +163,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { onChange={(value) => handleTimeframeChange(value as '1d' | '7d' | '30d')} size="sm" variant="default" + equalWidth /> @@ -178,7 +179,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { height={400} id="volume-chart" > - + - + formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} /> new Date(unixTime * 1000).toLocaleString()} - formatter={(value: number, name: string) => [formatValue(value), name]} - contentStyle={{ - backgroundColor: 'var(--color-background)', + cursor={{ stroke: 'var(--color-text-secondary)', strokeWidth: 1, strokeDasharray: '4 4' }} + content={({ active, payload, label }) => { + if (!active || !payload) return null; + return ( +
+

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

+
+ {payload.map((entry: any) => ( +
+
+ + {entry.name} +
+ {formatValue(entry.value)} +
+ ))} +
+
+ ); }} /> { const dataKey = e.dataKey as keyof typeof visibleLines; setVisibleLines((prev) => ({ @@ -261,8 +299,11 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { }} formatter={(value, entry) => ( {value} @@ -315,7 +356,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { className="flex items-center justify-between" > {type}: - + {formatValue(stats.current)} 0 ? 'ml-2 text-green-500' : 'ml-2 text-red-500'}> ({stats.netChangePercentage > 0 ? '+' : ''} @@ -345,7 +386,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { - + {formatValue(Number(formatUnits(targetUtilizationData.supplyDelta, market.loanAsset.decimals)))} @@ -366,7 +407,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { - + {formatValue(Number(formatUnits(targetUtilizationData.borrowDelta, market.loanAsset.decimals)))} @@ -388,7 +429,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { className="flex items-center justify-between" > {type}: - + {formatValue(getAverageVolumeStats(type as 'supply' | 'borrow' | 'liquidity'))} From bf31f7ea36af63c023c4abaa2ec220951225d31e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 18:07:04 +0800 Subject: [PATCH 2/7] refactor: redesign header --- app/global.css | 7 + src/components/Info/info.tsx | 10 +- src/components/shared/address-identity.tsx | 44 ++ .../components/charts/rate-chart.tsx | 528 +++++++-------- .../components/charts/volume-chart.tsx | 618 +++++++++--------- .../components/market-header.tsx | 396 +++++++++++ .../components/position-pill.tsx | 144 ++++ src/features/market-detail/market-view.tsx | 234 +------ .../MarketOracle/MarketOracleFeedInfo.tsx | 52 +- src/utils/warnings.ts | 32 +- 10 files changed, 1267 insertions(+), 798 deletions(-) create mode 100644 src/components/shared/address-identity.tsx create mode 100644 src/features/market-detail/components/market-header.tsx create mode 100644 src/features/market-detail/components/position-pill.tsx diff --git a/app/global.css b/app/global.css index bb662242..abeb6426 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..abf1c7aa 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-amber-100 text-amber-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 ''; } @@ -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/shared/address-identity.tsx b/src/components/shared/address-identity.tsx new file mode 100644 index 00000000..f5ce89c6 --- /dev/null +++ b/src/components/shared/address-identity.tsx @@ -0,0 +1,44 @@ +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; +}; + +export function AddressIdentity({ address, chainId, label, isToken, tokenSymbol }: AddressIdentityProps) { + return ( + + {isToken ? ( + + ) : ( + + )} + {label && {label}} + + {address.slice(0, 6)}...{address.slice(-4)} + + + + ); +} diff --git a/src/features/market-detail/components/charts/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index 38ec8ec0..119f44c3 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -1,10 +1,10 @@ /* 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'; @@ -23,14 +23,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 +46,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, @@ -76,12 +73,12 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { return isAprDisplay ? convertApyToApr(avgApy) : avgApy; }; - const getCurrentapyAtTargetValue = () => { + const getCurrentApyAtTargetValue = () => { const apy = market.state.apyAtTarget; return isAprDisplay ? convertApyToApr(apy) : apy; }; - const getAverageapyAtTargetValue = () => { + 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) / @@ -101,267 +98,282 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { ); }; - const timeframeOptions = [ - { key: '1d', label: '1D', value: '1d' }, - { key: '7d', label: '7D', value: '7d' }, - { key: '30d', label: '30D', value: '30d' }, - ]; + const timeframeLabels: Record = { + '1d': '1D', + '7d': '7D', + '30d': '30D', + }; return ( - - - handleTimeframeChange(value as '1d' | '7d' | '30d')} - size="sm" - variant="default" - equalWidth - /> - - -
-
- {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} - /> - { - if (!active || !payload) return null; - return ( -
-

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

-
- {payload.map((entry: any) => ( -
-
- - {entry.name} -
- {`${(entry.value * 100).toFixed(2)}%`} -
- ))} -
-
- ); - }} + + {/* Header: Live Stats + Controls */} +
+ {/* Live Stats */} +
+ {/* Supply Rate */} +
+

Supply {rateLabel}

+

{formatPercentage(getCurrentApyValue('supply'))}

+
+ + {/* Borrow Rate */} +
+

Borrow {rateLabel}

+

{formatPercentage(getCurrentApyValue('borrow'))}

+
+ + {/* Rate at Target */} +
+

Rate at Target

+

{formatPercentage(getCurrentApyAtTargetValue())}

+
+ + {/* Utilization */} +
+

Utilization

+
+ + {formatPercentage(getCurrentUtilizationRate())} +
+
+
+ + {/* Controls */} +
+ +
+
+ + {/* Chart Body - Full Width */} +
+ {isLoading ? ( +
+ +
+ ) : ( + + + + + - { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} + - + + - - + + - - - )} -
-
-
-
-

Current Rates

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

- Historical Averages ({selectedTimeframe}) -

- {isLoading ? ( -
- -
- ) : ( - <> -
- Utilization Rate: - {formatPercentage(getAverageUtilizationRate())} -
-
- Supply APY: - {formatPercentage(getAverageApyValue('supply'))} -
-
- Borrow APY: - {formatPercentage(getAverageApyValue('borrow'))} -
-
- Rate at U Target: - {formatPercentage(getAverageapyAtTargetValue())} + + + + 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} + /> + { + if (!active || !payload) return null; + return ( +
+

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

+
+ {payload.map((entry: any) => ( +
+
+ + {entry.name} +
+ {`${(entry.value * 100).toFixed(2)}%`} +
+ ))} +
- + ); + }} + /> + { + const dataKey = e.dataKey as keyof typeof visibleLines; + setVisibleLines((prev) => ({ + ...prev, + [dataKey]: !prev[dataKey], + })); + }} + formatter={(value, entry) => ( + + {value} + )} -
+ /> + + + + + + )} +
+ + {/* Footer: Historical Averages */} +
+

+ {timeframeLabels[selectedTimeframe]} Averages +

+ {isLoading ? ( +
+ +
+ ) : ( +
+
+ Utilization + {formatPercentage(getAverageUtilizationRate())} +
+
+ Supply {rateLabel} + {formatPercentage(getAverageApyValue('supply'))} +
+
+ Borrow {rateLabel} + {formatPercentage(getAverageApyValue('borrow'))} +
+
+ Rate at Target + {formatPercentage(getAverageApyAtTargetValue())}
-
- + )} +
); } diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 2a08ce78..44237abc 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -1,12 +1,11 @@ /* 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'; @@ -24,14 +23,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 +55,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; } @@ -118,328 +111,345 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { return sum / data.length; }; - 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 timeframeLabels: Record = { + '1d': '1D', + '7d': '7D', + '30d': '30D', + }; - // 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 = getCurrentVolumeStats('supply'); + const borrowStats = getCurrentVolumeStats('borrow'); + const liquidityStats = getCurrentVolumeStats('liquidity'); + return ( - - - setVolumeView(value as 'USD' | 'Asset')} - size="sm" - variant="default" - /> - handleTimeframeChange(value as '1d' | '7d' | '30d')} - size="sm" - variant="default" - equalWidth - /> - - -
-
- {isLoading ? ( -
- -
- ) : ( - - - - - - - - - - - - - - - - - + {/* 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)' }} + - + + - { - if (!active || !payload) return null; - return ( -
-

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

-
- {payload.map((entry: any) => ( -
-
- - {entry.name} -
- {formatValue(entry.value)} -
- ))} -
-
- ); - }} + - { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} +
+ + - - - -
-
- )} -
-
-
-
-

Current Volumes

- {['supply', 'borrow', 'liquidity'].map((type) => { - const stats = getCurrentVolumeStats(type as 'supply' | 'borrow' | 'liquidity'); + + + + formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + { + if (!active || !payload) return null; return ( -
- {type}: - - {formatValue(stats.current)} - 0 ? 'ml-2 text-green-500' : 'ml-2 text-red-500'}> - ({stats.netChangePercentage > 0 ? '+' : ''} - {stats.netChangePercentage.toFixed(2)}%) - - +
+

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

+
+ {payload.map((entry: any) => ( +
+
+ + {entry.name} +
+ {formatValue(entry.value)} +
+ ))} +
); - })} + }} + /> + { + const dataKey = e.dataKey as keyof typeof visibleLines; + setVisibleLines((prev) => ({ + ...prev, + [dataKey]: !prev[dataKey], + })); + }} + formatter={(value, entry) => ( + + {value} + + )} + /> + + + + + + )} +
- {/* Delta to target Utilization */} -
-

IRM Targets

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

{timeframeLabels[selectedTimeframe]} Averages

+ {isLoading ? ( +
+ +
+ ) : ( +
+
+ Supply + {formatValue(getAverageVolumeStats('supply'))}
- -
-

- Historical Averages ({selectedTimeframe}) -

- {isLoading ? ( -
- -
- ) : ( - ['supply', 'borrow', 'liquidity'].map((type) => ( -
- {type}: - - {formatValue(getAverageVolumeStats(type as 'supply' | 'borrow' | 'liquidity'))} - -
- )) - )} +
+ Borrow + {formatValue(getAverageVolumeStats('borrow'))} +
+
+ Liquidity + {formatValue(getAverageVolumeStats('liquidity'))}
-
+ )}
- +
); } 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..7d9f5924 --- /dev/null +++ b/src/features/market-detail/components/market-header.tsx @@ -0,0 +1,396 @@ +'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 } from 'react-icons/io5'; +import { MdError } from 'react-icons/md'; +import { Button } from '@/components/ui/button'; +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 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-amber-200 bg-amber-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-amber-100 text-amber-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; + warnings: WarningWithDetail[]; + allWarnings: WarningWithDetail[]; + onSupplyClick: () => void; + onBorrowClick: () => void; +}; + +export function MarketHeader({ + market, + marketId, + network, + userPosition, + oraclePrice, + warnings, + 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)}%`; + + // 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 + Action Buttons */} +
+ {userPosition && ( + + )} + + +
+
+
+ + {/* 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

+ +
+ + {/* All 3 address badges */} +
+ + + +
+ + {/* Asset warnings */} + +
+ + {/* RIGHT: Oracle */} +
+
+

Oracle

+ +
+ + + + {/* Oracle warnings */} + +
+
+
+
+
+
+ ); +} 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..0414afd3 --- /dev/null +++ b/src/features/market-detail/components/position-pill.tsx @@ -0,0 +1,144 @@ +'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 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)); + + // Calculate total position value in USD + const supplyUsd = supplyAmount * (market.loanAsset.priceUsd ?? 0); + const borrowUsd = borrowAmount * (market.loanAsset.priceUsd ?? 0); + const collateralUsd = collateralAmount * (market.collateralAsset.priceUsd ?? 0); + const netValue = supplyUsd + collateralUsd - borrowUsd; + + // Check if user has any position + const hasPosition = supplyAmount > 0 || borrowAmount > 0 || collateralAmount > 0; + + if (!hasPosition) { + return null; + } + + return ( + + + + + +
+

Your Position

+ + {supplyAmount > 0 && ( +
+
+ + Supplied +
+
+ + {formatReadable(supplyAmount)} {market.loanAsset.symbol} + +

${formatReadable(supplyUsd)}

+
+
+ )} + + {borrowAmount > 0 && ( +
+
+ + Borrowed +
+
+ + {formatReadable(borrowAmount)} {market.loanAsset.symbol} + +

${formatReadable(borrowUsd)}

+
+
+ )} + + {collateralAmount > 0 && ( +
+
+ + Collateral +
+
+ + {formatReadable(collateralAmount)} {market.collateralAsset.symbol} + +

${formatReadable(collateralUsd)}

+
+
+ )} + + {/* 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..77f8ece3 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -3,45 +3,31 @@ '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 +36,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(); @@ -215,67 +200,36 @@ function MarketContent() { ); } - // 8. Derived values that depend on market data - const cardStyle = 'bg-surface rounded shadow-sm p-4'; + // 8. Warning filtering by category (for MarketHeader) + const warnings = allWarnings.filter( + (w) => w.category === WarningCategory.debt || w.category === WarningCategory.general, + ); + + // 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/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/utils/warnings.ts b/src/utils/warnings.ts index 7cd121ae..b43c3048 100644 --- a/src/utils/warnings.ts +++ b/src/utils/warnings.ts @@ -209,16 +209,14 @@ export const getMarketWarningsWithDetail = (market: Market, considerWhitelist = if (feedsPathResult.hasUnknownFeed) { // only append this error if it doesn't already have "UNRECOGNIZED_FEEDS" - if (result.find((w) => 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 }; +}; From b479704570981144705cde66158b05f203341dba Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 18:38:30 +0800 Subject: [PATCH 3/7] misc: fix icons --- .claude/skills/icons/SKILL.md | 7 +- app/global.css | 4 +- src/components/Info/info.tsx | 20 ++-- .../layout/header/TransactionIndicator.tsx | 12 +- .../components/campaign-badge.tsx | 24 +++- .../components/charts/rate-chart.tsx | 12 +- .../components/charts/volume-chart.tsx | 21 ++-- .../components/market-header.tsx | 113 ++++++++++++++---- .../components/position-pill.tsx | 38 ++---- src/features/market-detail/market-view.tsx | 5 +- 10 files changed, 153 insertions(+), 103 deletions(-) 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 abeb6426..01d88dea 100644 --- a/app/global.css +++ b/app/global.css @@ -294,8 +294,8 @@ color-scheme: dark; } -.dark .shadow-sm, -.dark .shadow-md, +.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 */ diff --git a/src/components/Info/info.tsx b/src/components/Info/info.tsx index abf1c7aa..c0a34a28 100644 --- a/src/components/Info/info.tsx +++ b/src/components/Info/info.tsx @@ -10,7 +10,7 @@ const levelToCellColor = (level: string) => { case 'success': return 'bg-green-100 text-green-800 dark:bg-green-400/10 dark:text-green-300'; case 'warning': - return 'bg-amber-100 text-amber-800 dark:bg-yellow-400/10 dark:text-yellow-300'; + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-300'; case 'alert': return 'bg-red-100 text-red-800 dark:bg-red-400/10 dark:text-red-300'; default: @@ -23,33 +23,33 @@ const levelToIcon = (level: string) => { case 'info': return ( ); case 'success': return ( ); case 'warning': return ( ); case 'alert': return ( ); default: - return ''; + return null; } }; 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/features/market-detail/components/campaign-badge.tsx b/src/features/market-detail/components/campaign-badge.tsx index 81f85d79..230a93b1 100644 --- a/src/features/market-detail/components/campaign-badge.tsx +++ b/src/features/market-detail/components/campaign-badge.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { FaGift } from 'react-icons/fa'; import { Badge } from '@/components/ui/badge'; import { useMarketCampaigns } from '@/hooks/useMarketCampaigns'; @@ -11,9 +11,15 @@ type CampaignBadgeProps = { loanTokenAddress: string; chainId: number; whitelisted: boolean; + filterType?: 'supply' | 'borrow'; }; -export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted }: CampaignBadgeProps) { +// Supply campaign types: MORPHOSUPPLY, MORPHOSUPPLY_SINGLETOKEN, MULTILENDBORROW +const SUPPLY_CAMPAIGN_TYPES = ['MORPHOSUPPLY', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; +// Borrow campaign types: MORPHOBORROW +const BORROW_CAMPAIGN_TYPES = ['MORPHOBORROW']; + +export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted, filterType }: CampaignBadgeProps) { const [isModalOpen, setIsModalOpen] = useState(false); const { activeCampaigns, hasActiveRewards, loading } = useMarketCampaigns({ @@ -23,11 +29,19 @@ export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted whitelisted, }); - if (loading || !hasActiveRewards) { + // Filter campaigns by type if filterType is specified + const filteredCampaigns = useMemo(() => { + 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/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index 119f44c3..2a5285c6 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -47,8 +47,8 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { return supplyApy.map((point: TimeseriesDataPoint, index: number) => { 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, @@ -300,9 +300,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { {value} @@ -346,9 +344,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { {/* Footer: Historical Averages */}
-

- {timeframeLabels[selectedTimeframe]} Averages -

+

{timeframeLabels[selectedTimeframe]} Averages

{isLoading ? (
diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 44237abc..5a925f6c 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -143,9 +143,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) {

Supply

- - {formatValue(supplyStats.current)} - + {formatValue(supplyStats.current)} = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> {supplyStats.netChangePercentage >= 0 ? '+' : ''} {supplyStats.netChangePercentage.toFixed(2)}% @@ -155,9 +153,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) {

Borrow

- - {formatValue(borrowStats.current)} - + {formatValue(borrowStats.current)} = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> {borrowStats.netChangePercentage >= 0 ? '+' : ''} {borrowStats.netChangePercentage.toFixed(2)}% @@ -167,9 +163,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) {

Liquidity

- - {formatValue(liquidityStats.current)} - + {formatValue(liquidityStats.current)} = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> {liquidityStats.netChangePercentage >= 0 ? '+' : ''} {liquidityStats.netChangePercentage.toFixed(2)}% @@ -220,7 +214,10 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { height={350} id="volume-chart" > - + {value} diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 7d9f5924..859b283b 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -4,9 +4,12 @@ import Image from 'next/image'; import { formatUnits } from 'viem'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { GrStatusGood } from 'react-icons/gr'; -import { IoWarningOutline } from 'react-icons/io5'; +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'; @@ -19,6 +22,7 @@ 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'; @@ -35,7 +39,7 @@ function WarningBlock({ warnings, riskLevel }: WarningBlockProps): React.ReactNo const isAlert = riskLevel === 'red'; const containerClass = isAlert ? 'border-red-200 bg-red-50 dark:border-red-400/20 dark:bg-red-400/10' - : 'border-amber-200 bg-amber-50 dark:border-yellow-400/20 dark:bg-yellow-400/10'; + : 'border-yellow-200 bg-yellow-50 dark:border-yellow-400/20 dark:bg-yellow-400/10'; return (
@@ -43,13 +47,13 @@ function WarningBlock({ warnings, riskLevel }: WarningBlockProps): React.ReactNo {isAlert ? ( ) : ( - + )}
{warnings.map((w) => (

{w.description}

@@ -70,7 +74,7 @@ type StatusBadgeProps = { 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-amber-100 text-amber-800 dark:bg-yellow-400/10 dark:text-yellow-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', }; @@ -94,11 +98,11 @@ function StatusBadge({ variant, count, label }: StatusBadgeProps): React.ReactNo function RiskIcon({ level }: { level: RiskLevel }): React.ReactNode { switch (level) { case 'green': - return ; + return ; case 'yellow': - return ; + return ; case 'red': - return ; + return ; default: return null; } @@ -222,17 +226,9 @@ export function MarketHeader({
-
-

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

- -
+

+ {market.loanAsset.symbol}/{market.collateralAsset.symbol} +

{networkImg && (
@@ -259,11 +255,29 @@ export function MarketHeader({

Supply {rateLabel}

-

{formatRate(market.state.supplyApy)}

+
+

{formatRate(market.state.supplyApy)}

+ +

Borrow {rateLabel}

-

{formatRate(market.state.borrowApy)}

+
+

{formatRate(market.state.borrowApy)}

+ +
- {/* Position Pill + Action Buttons */} + {/* Position Pill + Actions Dropdown */}
{userPosition && ( )} - - + + + + + + } + > + Supply + + } + > + Borrow + + window.open(getMarketURL(marketId, network), '_blank')} + startContent={} + > + View on Morpho + + +
@@ -301,11 +344,29 @@ export function MarketHeader({

Supply {rateLabel}

-

{formatRate(market.state.supplyApy)}

+
+

{formatRate(market.state.supplyApy)}

+ +

Borrow {rateLabel}

-

{formatRate(market.state.borrowApy)}

+
+

{formatRate(market.state.borrowApy)}

+ +

Oracle

diff --git a/src/features/market-detail/components/position-pill.tsx b/src/features/market-detail/components/position-pill.tsx index 0414afd3..cec9325a 100644 --- a/src/features/market-detail/components/position-pill.tsx +++ b/src/features/market-detail/components/position-pill.tsx @@ -19,12 +19,6 @@ export function PositionPill({ position, onSupplyClick, onBorrowClick }: Positio const borrowAmount = Number(formatUnits(BigInt(state.borrowAssets), market.loanAsset.decimals)); const collateralAmount = Number(formatUnits(BigInt(state.collateral), market.collateralAsset.decimals)); - // Calculate total position value in USD - const supplyUsd = supplyAmount * (market.loanAsset.priceUsd ?? 0); - const borrowUsd = borrowAmount * (market.loanAsset.priceUsd ?? 0); - const collateralUsd = collateralAmount * (market.collateralAsset.priceUsd ?? 0); - const netValue = supplyUsd + collateralUsd - borrowUsd; - // Check if user has any position const hasPosition = supplyAmount > 0 || borrowAmount > 0 || collateralAmount > 0; @@ -37,11 +31,10 @@ export function PositionPill({ position, onSupplyClick, onBorrowClick }: Positio Supplied
-
- - {formatReadable(supplyAmount)} {market.loanAsset.symbol} - -

${formatReadable(supplyUsd)}

-
+ + {formatReadable(supplyAmount)} {market.loanAsset.symbol} +
)} @@ -84,12 +74,9 @@ export function PositionPill({ position, onSupplyClick, onBorrowClick }: Positio /> Borrowed
-
- - {formatReadable(borrowAmount)} {market.loanAsset.symbol} - -

${formatReadable(borrowUsd)}

-
+ + {formatReadable(borrowAmount)} {market.loanAsset.symbol} +
)} @@ -105,12 +92,9 @@ export function PositionPill({ position, onSupplyClick, onBorrowClick }: Positio /> Collateral
-
- - {formatReadable(collateralAmount)} {market.collateralAsset.symbol} - -

${formatReadable(collateralUsd)}

-
+ + {formatReadable(collateralAmount)} {market.collateralAsset.symbol} +
)} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 77f8ece3..5d0f42b3 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -70,7 +70,6 @@ function MarketContent() { const { position: userPosition, - loading: positionLoading, refetch: refetchUserPosition, } = useUserPosition(address, network, marketId as string); @@ -201,9 +200,7 @@ function MarketContent() { } // 8. Warning filtering by category (for MarketHeader) - const warnings = allWarnings.filter( - (w) => w.category === WarningCategory.debt || w.category === WarningCategory.general, - ); + const warnings = allWarnings.filter((w) => w.category === WarningCategory.debt || w.category === WarningCategory.general); // Handlers for supply/borrow actions const handleSupplyClick = () => { From 0091e50bfb1a1cb4b65c8f2c898e7fdefb12c832 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 18:39:18 +0800 Subject: [PATCH 4/7] chore: remove unused --- .../components/market-warning-banner.tsx | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 src/features/market-detail/components/market-warning-banner.tsx 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}

- ))} -
-
-
- ); -} From f61ee6374a534eb68e6e88b5c1e64cfd90ed69d4 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 19:04:09 +0800 Subject: [PATCH 5/7] chore: cleanup --- src/components/shared/address-identity.tsx | 5 + .../components/charts/chart-utils.tsx | 125 +++++++++++ .../components/charts/rate-chart.tsx | 211 ++++-------------- .../components/charts/volume-chart.tsx | 181 ++++----------- .../components/market-header.tsx | 89 ++++---- .../components/position-pill.tsx | 105 +++++---- src/features/market-detail/market-view.tsx | 129 +++++------ .../oracle/MarketOracle/OracleTypeInfo.tsx | 47 ++-- 8 files changed, 405 insertions(+), 487 deletions(-) create mode 100644 src/features/market-detail/components/charts/chart-utils.tsx diff --git a/src/components/shared/address-identity.tsx b/src/components/shared/address-identity.tsx index f5ce89c6..2b5b263d 100644 --- a/src/components/shared/address-identity.tsx +++ b/src/components/shared/address-identity.tsx @@ -12,6 +12,11 @@ type AddressIdentityProps = { 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 ( = { + '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 2a5285c6..f7d80b0b 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-unstable-nested-components */ - import { useState, useMemo } from 'react'; import { Card } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; @@ -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'; @@ -61,48 +68,24 @@ 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 toDisplayRate = (apy: number) => (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 getAverage = (data: TimeseriesDataPoint[] | undefined) => { + if (!data || data.length === 0) return 0; + return data.reduce((sum, point) => sum + point.y, 0) / data.length; }; - const getCurrentApyAtTargetValue = () => { - const apy = market.state.apyAtTarget; - return isAprDisplay ? convertApyToApr(apy) : apy; - }; + const currentSupplyRate = toDisplayRate(market.state.supplyApy); + const currentBorrowRate = toDisplayRate(market.state.borrowApy); + const currentApyAtTarget = toDisplayRate(market.state.apyAtTarget); + const currentUtilization = market.state.utilization; - 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 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 getCurrentUtilizationRate = () => { - return 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 timeframeLabels: Record = { - '1d': '1D', - '7d': '7D', - '30d': '30D', - }; + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); return ( @@ -110,38 +93,29 @@ function RateChart({ marketId, chainId, market }: RateChartProps) {
{/* Live Stats */}
- {/* Supply Rate */}

Supply {rateLabel}

-

{formatPercentage(getCurrentApyValue('supply'))}

+

{formatPercentage(currentSupplyRate)}

- - {/* Borrow Rate */}

Borrow {rateLabel}

-

{formatPercentage(getCurrentApyValue('borrow'))}

+

{formatPercentage(currentBorrowRate)}

- - {/* Rate at Target */}

Rate at Target

-

{formatPercentage(getCurrentApyAtTargetValue())}

+

{formatPercentage(currentApyAtTarget)}

- - {/* Utilization */}

Utilization

- {formatPercentage(getCurrentUtilizationRate())} + {formatPercentage(currentUtilization)}
@@ -153,7 +127,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { onValueChange={(value) => handleTimeframeChange(value as '1d' | '7d' | '30d')} > - {timeframeLabels[selectedTimeframe]} + {TIMEFRAME_LABELS[selectedTimeframe]} 1D @@ -180,62 +154,10 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { data={getChartData} margin={{ top: 20, right: 20, left: 10, bottom: 10 }} > - - - - - - - - - - - - - - + { - if (!active || !payload) return null; - return ( -
-

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

-
- {payload.map((entry: any) => ( -
-
- - {entry.name} -
- {`${(entry.value * 100).toFixed(2)}%`} -
- ))} -
-
- ); - }} + cursor={chartTooltipCursor} + content={({ active, payload, label }) => ( + + )} /> { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} + {...chartLegendStyle} + {...legendHandlers} /> -

{timeframeLabels[selectedTimeframe]} Averages

+

{TIMEFRAME_LABELS[selectedTimeframe]} Averages

{isLoading ? (
@@ -353,19 +240,19 @@ function RateChart({ marketId, chainId, market }: RateChartProps) {
Utilization - {formatPercentage(getAverageUtilizationRate())} + {formatPercentage(avgUtilization)}
Supply {rateLabel} - {formatPercentage(getAverageApyValue('supply'))} + {formatPercentage(avgSupplyRate)}
Borrow {rateLabel} - {formatPercentage(getAverageApyValue('borrow'))} + {formatPercentage(avgBorrowRate)}
Rate at Target - {formatPercentage(getAverageApyAtTargetValue())} + {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 5a925f6c..3169b4a1 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-unstable-nested-components */ - import { useState, useMemo } from 'react'; import { Card } from '@/components/ui/card'; import { Tooltip as HeroTooltip } from '@/components/ui/tooltip'; @@ -13,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'; @@ -85,37 +92,22 @@ 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 timeframeLabels: Record = { - '1d': '1D', - '7d': '7D', - '30d': '30D', - }; + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); const targetUtilizationData = useMemo(() => { const supply = market.state.supplyAssets ? BigInt(market.state.supplyAssets) : 0n; @@ -130,9 +122,9 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { return { borrowDelta, supplyDelta }; }, [market.state.supplyAssets, market.state.borrowAssets]); - const supplyStats = getCurrentVolumeStats('supply'); - const borrowStats = getCurrentVolumeStats('borrow'); - const liquidityStats = getCurrentVolumeStats('liquidity'); + const supplyStats = getVolumeStats('supply'); + const borrowStats = getVolumeStats('borrow'); + const liquidityStats = getVolumeStats('liquidity'); return ( @@ -191,7 +183,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { onValueChange={(value) => handleTimeframeChange(value as '1d' | '7d' | '30d')} > - {timeframeLabels[selectedTimeframe]} + {TIMEFRAME_LABELS[selectedTimeframe]} 1D @@ -218,62 +210,10 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { data={getVolumeChartData()} margin={{ top: 20, right: 20, left: 10, bottom: 10 }} > - - - - - - - - - - - - - - + { - if (!active || !payload) return null; - return ( -
-

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

-
- {payload.map((entry: any) => ( -
-
- - {entry.name} -
- {formatValue(entry.value)} -
- ))} -
-
- ); - }} + cursor={chartTooltipCursor} + content={({ active, payload, label }) => ( + + )} /> { - const dataKey = e.dataKey as keyof typeof visibleLines; - setVisibleLines((prev) => ({ - ...prev, - [dataKey]: !prev[dataKey], - })); - }} - formatter={(value, entry) => ( - - {value} - - )} + {...chartLegendStyle} + {...legendHandlers} /> -

{timeframeLabels[selectedTimeframe]} Averages

+

{TIMEFRAME_LABELS[selectedTimeframe]} Averages

{isLoading ? (
@@ -431,15 +336,15 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) {
Supply - {formatValue(getAverageVolumeStats('supply'))} + {formatValue(supplyStats.average)}
Borrow - {formatValue(getAverageVolumeStats('borrow'))} + {formatValue(borrowStats.average)}
Liquidity - {formatValue(getAverageVolumeStats('liquidity'))} + {formatValue(liquidityStats.average)}
)} diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 859b283b..ad905cc9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -114,7 +114,6 @@ type MarketHeaderProps = { network: SupportedNetworks; userPosition: MarketPosition | null; oraclePrice: string; - warnings: WarningWithDetail[]; allWarnings: WarningWithDetail[]; onSupplyClick: () => void; onBorrowClick: () => void; @@ -126,7 +125,6 @@ export function MarketHeader({ network, userPosition, oraclePrice, - warnings, allWarnings, onSupplyClick, onBorrowClick, @@ -142,6 +140,13 @@ export function MarketHeader({ 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); @@ -203,7 +208,7 @@ export function MarketHeader({ return (
{/* Main Header */} -
+
{/* LEFT: Market Identity */}
@@ -226,9 +231,9 @@ export function MarketHeader({
-

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

+
{networkImg && (
@@ -258,10 +263,7 @@ export function MarketHeader({

{formatRate(market.state.supplyApy)}

@@ -271,10 +273,7 @@ export function MarketHeader({

{formatRate(market.state.borrowApy)}

@@ -310,7 +309,7 @@ export function MarketHeader({ @@ -347,10 +346,7 @@ export function MarketHeader({

{formatRate(market.state.supplyApy)}

@@ -360,10 +356,7 @@ export function MarketHeader({

{formatRate(market.state.borrowApy)}

@@ -398,27 +391,35 @@ export function MarketHeader({
- {/* All 3 address badges */} -
- - - +
+
+ Loan: + +
+
+ Collateral: + +
+
+ IRM: + +
{/* Asset warnings */} @@ -439,7 +440,7 @@ export function MarketHeader({ oracleData={market.oracle?.data} oracleAddress={market.oracleAddress} chainId={market.morphoBlue.chain.id} - showLink + useBadge /> {/* Oracle warnings */} diff --git a/src/features/market-detail/components/position-pill.tsx b/src/features/market-detail/components/position-pill.tsx index cec9325a..452dedaa 100644 --- a/src/features/market-detail/components/position-pill.tsx +++ b/src/features/market-detail/components/position-pill.tsx @@ -6,6 +6,36 @@ 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; @@ -44,59 +74,28 @@ export function PositionPill({ position, onSupplyClick, onBorrowClick }: Positio

Your Position

- {supplyAmount > 0 && ( -
-
- - Supplied -
- - {formatReadable(supplyAmount)} {market.loanAsset.symbol} - -
- )} - - {borrowAmount > 0 && ( -
-
- - Borrowed -
- - {formatReadable(borrowAmount)} {market.loanAsset.symbol} - -
- )} - - {collateralAmount > 0 && ( -
-
- - Collateral -
- - {formatReadable(collateralAmount)} {market.collateralAsset.symbol} - -
- )} + + + {/* Action buttons */} {(onSupplyClick ?? onBorrowClick) && ( diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 5d0f42b3..31ff4d15 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -25,7 +25,6 @@ import { SuppliersTable } from '@/features/market-detail/components/suppliers-ta 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 { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; @@ -68,91 +67,79 @@ function MarketContent() { const { address } = useConnection(); - const { - position: userPosition, - 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 () => { @@ -199,9 +186,6 @@ function MarketContent() { ); } - // 8. Warning filtering by category (for MarketHeader) - const warnings = allWarnings.filter((w) => w.category === WarningCategory.debt || w.category === WarningCategory.general); - // Handlers for supply/borrow actions const handleSupplyClick = () => { openModal('supply', { market, position: userPosition, isMarketPage: true, refetch: handleRefreshAllSync }); @@ -222,7 +206,6 @@ function MarketContent() { network={network} userPosition={userPosition} oraclePrice={formattedOraclePrice} - warnings={warnings} allWarnings={allWarnings} onSupplyClick={handleSupplyClick} onBorrowClick={handleBorrowClick} 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 ? ( Date: Tue, 13 Jan 2026 19:08:19 +0800 Subject: [PATCH 6/7] chore: styling --- .../markets/components/oracle/MarketOracle/FeedEntry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From 17916794e60f949c5a75210c8415fb2743e95471 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 19:09:38 +0800 Subject: [PATCH 7/7] chore: fix --- src/features/market-detail/components/market-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index ad905cc9..847763ea 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -309,7 +309,7 @@ export function MarketHeader({