diff --git a/src/components/ui/split-action-button.tsx b/src/components/ui/split-action-button.tsx
new file mode 100644
index 00000000..07772a9f
--- /dev/null
+++ b/src/components/ui/split-action-button.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { ChevronDownIcon } from '@radix-ui/react-icons';
+import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
+import { Tooltip } from '@/components/ui/tooltip';
+import { cn } from '@/utils/index';
+
+type DropdownItem = {
+ label: string;
+ icon?: ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+};
+
+type IndicatorConfig = {
+ show: boolean;
+ tooltip?: ReactNode;
+};
+
+type SplitActionButtonProps = {
+ label: string;
+ icon?: ReactNode;
+ onClick: () => void;
+ indicator?: IndicatorConfig;
+ dropdownItems: DropdownItem[];
+ className?: string;
+};
+
+export function SplitActionButton({
+ label,
+ icon,
+ onClick,
+ indicator,
+ dropdownItems,
+ className,
+}: SplitActionButtonProps): ReactNode {
+ const showIndicator = indicator?.show ?? false;
+
+ const mainButton = (
+
+ );
+
+ const wrappedButton = showIndicator && indicator?.tooltip
+ ? {mainButton}
+ : mainButton;
+
+ return (
+
+ {wrappedButton}
+
+
+
+
+
+
+ {dropdownItems.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx
index b093262b..0b3f84b0 100644
--- a/src/features/market-detail/components/market-header.tsx
+++ b/src/features/market-detail/components/market-header.tsx
@@ -8,22 +8,28 @@ 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, BsFillLightningFill, BsArrowRepeat } from 'react-icons/bs';
+import { BsArrowUpCircle, BsArrowDownLeftCircle, BsFillLightningFill } from 'react-icons/bs';
+import { GoStarFill, GoStar } from 'react-icons/go';
+import { AiOutlineStop } from 'react-icons/ai';
import { FiExternalLink } from 'react-icons/fi';
-import { LuCopy } from 'react-icons/lu';
+import { LuCopy, LuArrowDownToLine, LuRefreshCw } from 'react-icons/lu';
import { Button } from '@/components/ui/button';
+import { SplitActionButton } from '@/components/ui/split-action-button';
+import { useMarketPreferences } from '@/stores/useMarketPreferences';
+import { useBlacklistedMarkets } from '@/stores/useBlacklistedMarkets';
+import { BlacklistConfirmationModal } from '@/features/markets/components/blacklist-confirmation-modal';
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 { useStyledToast } from '@/hooks/useStyledToast';
import { useAppSettings } from '@/stores/useAppSettings';
import { convertApyToApr } from '@/utils/rateMath';
+import { formatReadable } from '@/utils/balance';
import { getIRMTitle } from '@/utils/morpho';
import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks';
import { getMarketURL } from '@/utils/external';
@@ -112,6 +118,127 @@ function RiskIcon({ level }: { level: RiskLevel }): React.ReactNode {
}
}
+// Extracted action buttons component for cleaner code
+type ActionButtonsProps = {
+ market: Market;
+ userPosition: MarketPosition | null;
+ onSupplyClick: () => void;
+ onWithdrawClick: () => void;
+ onBorrowClick: () => void;
+ onRepayClick: () => void;
+};
+
+function ActionButtons({
+ market,
+ userPosition,
+ onSupplyClick,
+ onWithdrawClick,
+ onBorrowClick,
+ onRepayClick,
+}: ActionButtonsProps): React.ReactNode {
+ // Compute position states once
+ const hasSupply = userPosition !== null && BigInt(userPosition.state.supplyShares) > 0n;
+ const hasBorrow = userPosition !== null && BigInt(userPosition.state.borrowShares) > 0n;
+ const hasCollateral = userPosition !== null && BigInt(userPosition.state.collateral) > 0n;
+ const hasBorrowPosition = hasBorrow || hasCollateral;
+
+ const supplyTooltip =
+ hasSupply && userPosition ? (
+
+
+
+
Supplied
+
+ {formatReadable(Number(formatUnits(BigInt(userPosition.state.supplyAssets), market.loanAsset.decimals)))}{' '}
+ {market.loanAsset.symbol}
+
+
+
+ ) : undefined;
+
+ const borrowTooltip =
+ hasBorrowPosition && userPosition ? (
+
+ {hasCollateral && (
+
+
+
+
Collateral
+
+ {formatReadable(Number(formatUnits(BigInt(userPosition.state.collateral), market.collateralAsset.decimals)))}{' '}
+ {market.collateralAsset.symbol}
+
+
+
+ )}
+ {hasBorrow && (
+
+
+
+
Borrowed
+
+ {formatReadable(Number(formatUnits(BigInt(userPosition.state.borrowAssets), market.loanAsset.decimals)))}{' '}
+ {market.loanAsset.symbol}
+
+
+
+ )}
+
+ ) : undefined;
+
+ return (
+ <>
+ }
+ onClick={onSupplyClick}
+ indicator={{ show: hasSupply, tooltip: supplyTooltip }}
+ dropdownItems={[
+ {
+ label: 'Withdraw',
+ icon: ,
+ onClick: onWithdrawClick,
+ disabled: !hasSupply,
+ },
+ ]}
+ />
+
+ }
+ onClick={onBorrowClick}
+ indicator={{ show: hasBorrowPosition, tooltip: borrowTooltip }}
+ dropdownItems={[
+ {
+ label: 'Repay',
+ icon: ,
+ onClick: onRepayClick,
+ disabled: !hasBorrow,
+ },
+ ]}
+ />
+ >
+ );
+}
+
type MarketHeaderProps = {
market: Market;
marketId: string;
@@ -120,9 +247,10 @@ type MarketHeaderProps = {
oraclePrice: string;
allWarnings: WarningWithDetail[];
onSupplyClick: () => void;
+ onWithdrawClick: () => void;
onBorrowClick: () => void;
+ onRepayClick: () => void;
accrueInterest: () => void;
- onPullLiquidity: () => void;
};
export function MarketHeader({
@@ -133,15 +261,40 @@ export function MarketHeader({
oraclePrice,
allWarnings,
onSupplyClick,
+ onWithdrawClick,
onBorrowClick,
+ onRepayClick,
accrueInterest,
- onPullLiquidity,
}: MarketHeaderProps) {
const [isExpanded, setIsExpanded] = useState(false);
+ const [isBlacklistModalOpen, setIsBlacklistModalOpen] = useState(false);
const { short: rateLabel } = useRateLabel();
- const { isAprDisplay, showDeveloperOptions, usePublicAllocator } = useAppSettings();
+ const { isAprDisplay, showDeveloperOptions } = useAppSettings();
+ const { starredMarkets, starMarket, unstarMarket } = useMarketPreferences();
+ const { isBlacklisted, addBlacklistedMarket } = useBlacklistedMarkets();
const toast = useStyledToast();
const networkImg = getNetworkImg(network);
+ const isStarred = starredMarkets.includes(market.uniqueKey);
+
+ const handleToggleStar = () => {
+ if (isStarred) {
+ unstarMarket(market.uniqueKey);
+ toast.success('Market unstarred', 'Removed from favorites');
+ } else {
+ starMarket(market.uniqueKey);
+ toast.success('Market starred', 'Added to favorites');
+ }
+ };
+
+ const handleBlacklistClick = () => {
+ if (!isBlacklisted(market.uniqueKey)) {
+ setIsBlacklistModalOpen(true);
+ }
+ };
+
+ const handleConfirmBlacklist = () => {
+ addBlacklistedMarket(market.uniqueKey, market.morphoBlue.chain.id);
+ };
const handleCopyMarketId = async () => {
try {
@@ -323,46 +476,43 @@ export function MarketHeader({
- {/* Position Pill + Actions Dropdown */}
+ {/* Action Buttons + Dropdown */}
- {userPosition && (
-
- )}
+
+
+ {/* Advanced Options Dropdown */}
}
+ onClick={handleToggleStar}
+ startContent={isStarred ? : }
>
- Supply
+ {isStarred ? 'Unstar' : 'Star'}
}
+ onClick={handleBlacklistClick}
+ startContent={}
+ className={isBlacklisted(market.uniqueKey) ? 'opacity-50 cursor-not-allowed' : ''}
+ disabled={isBlacklisted(market.uniqueKey)}
>
- Borrow
+ {isBlacklisted(market.uniqueKey) ? 'Blacklisted' : 'Blacklist'}
- {usePublicAllocator && (
- }
- >
- Source Liquidity
-
- )}
{showDeveloperOptions && (
+
);
}
diff --git a/src/features/market-detail/components/position-pill.tsx b/src/features/market-detail/components/position-pill.tsx
deleted file mode 100644
index 452dedaa..00000000
--- a/src/features/market-detail/components/position-pill.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-'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 da0e548c..aae5a4e8 100644
--- a/src/features/market-detail/market-view.tsx
+++ b/src/features/market-detail/market-view.tsx
@@ -1,5 +1,3 @@
-// eslint-disable @typescript-eslint/prefer-nullish-coalescing
-
'use client';
import { useState, useCallback, useMemo } from 'react';
@@ -7,7 +5,6 @@ import { useParams } from 'next/navigation';
import { parseUnits, formatUnits, type Address, encodeFunctionData } from 'viem';
import { useConnection, useSwitchChain } from 'wagmi';
import morphoAbi from '@/abis/morpho';
-import { BorrowModal } from '@/modals/borrow/borrow-modal';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Spinner } from '@/components/ui/spinner';
import Header from '@/components/layout/header/Header';
@@ -31,7 +28,6 @@ import { useMarketWarnings } from '@/hooks/useMarketWarnings';
import { useMarketLiquiditySourcing } from '@/hooks/useMarketLiquiditySourcing';
import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMarketPositions';
import { MarketHeader } from './components/market-header';
-import { PullLiquidityModal } from './components/pull-liquidity-modal';
import RateChart from './components/charts/rate-chart';
import VolumeChart from './components/charts/volume-chart';
import { SuppliersPieChart } from './components/charts/suppliers-pie-chart';
@@ -51,9 +47,6 @@ function MarketContent() {
const { open: openModal } = useModal();
const selectedTab = useMarketDetailPreferences((s) => s.selectedTab);
const setSelectedTab = useMarketDetailPreferences((s) => s.setSelectedTab);
- const [showBorrowModal, setShowBorrowModal] = useState(false);
- const [showPullLiquidityModal, setShowPullLiquidityModal] = useState(false);
- const [isRefreshing, setIsRefreshing] = useState(false);
const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false);
const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false);
const [minSupplierShares, setMinSupplierShares] = useState('');
@@ -217,25 +210,11 @@ function MarketContent() {
});
}, [suppliersData]);
- // Unified refetch function for both market and user position
- const handleRefreshAll = useCallback(async () => {
- setIsRefreshing(true);
- try {
- await Promise.all([refetchMarket(), refetchUserPosition()]);
- } catch (error) {
- console.error('Failed to refresh data:', error);
- } finally {
- setIsRefreshing(false);
- }
+ // Refetch function for both market and user position
+ const handleRefresh = useCallback(() => {
+ void Promise.all([refetchMarket(), refetchUserPosition()]);
}, [refetchMarket, refetchUserPosition]);
- // Non-async wrapper for components that expect void returns
- const handleRefreshAllSync = useCallback(() => {
- void handleRefreshAll().catch((error) => {
- console.error('Failed to refresh data:', error);
- });
- }, [handleRefreshAll]);
-
// 7. Early returns for loading/error states
if (isMarketLoading) {
return (
@@ -264,11 +243,33 @@ function MarketContent() {
// Handlers for supply/borrow actions
const handleSupplyClick = () => {
- openModal('supply', { market, position: userPosition, isMarketPage: true, refetch: handleRefreshAllSync, liquiditySourcing });
+ openModal('supply', {
+ market,
+ position: userPosition,
+ isMarketPage: true,
+ refetch: handleRefresh,
+ liquiditySourcing,
+ defaultMode: 'supply',
+ });
+ };
+
+ const handleWithdrawClick = () => {
+ openModal('supply', {
+ market,
+ position: userPosition,
+ isMarketPage: true,
+ refetch: handleRefresh,
+ liquiditySourcing,
+ defaultMode: 'withdraw',
+ });
};
const handleBorrowClick = () => {
- setShowBorrowModal(true);
+ openModal('borrow', { market, refetch: handleRefresh, liquiditySourcing, defaultMode: 'borrow' });
+ };
+
+ const handleRepayClick = () => {
+ openModal('borrow', { market, refetch: handleRefresh, liquiditySourcing, defaultMode: 'repay' });
};
const handleAccrueInterest = async () => {
@@ -308,32 +309,12 @@ function MarketContent() {
oraclePrice={formattedOraclePrice}
allWarnings={allWarnings}
onSupplyClick={handleSupplyClick}
+ onWithdrawClick={handleWithdrawClick}
onBorrowClick={handleBorrowClick}
+ onRepayClick={handleRepayClick}
accrueInterest={handleAccrueInterest}
- onPullLiquidity={() => setShowPullLiquidityModal(true)}
/>
- {showBorrowModal && (
-
- )}
-
- {showPullLiquidityModal && (
-
- )}
-
{showTransactionFiltersModal && (
{
openModal('supply', { market });
}}
- startContent={}
+ startContent={}
>
Supply
+ {
+ openModal('borrow', { market });
+ }}
+ startContent={}
+ >
+ Borrow
+
+
}
diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx
index 56276edd..d59b35d9 100644
--- a/src/features/positions/components/supplied-markets-detail.tsx
+++ b/src/features/positions/components/supplied-markets-detail.tsx
@@ -82,7 +82,6 @@ function MarketRow({ position, totalSupply, rateLabel }: { position: MarketPosit
market: position.market,
position,
defaultMode: 'withdraw',
- isMarketPage: false,
});
}}
>
@@ -95,7 +94,6 @@ function MarketRow({ position, totalSupply, rateLabel }: { position: MarketPosit
open('supply', {
market: position.market,
position,
- isMarketPage: false,
});
}}
>
diff --git a/src/modals/borrow/borrow-modal-global.tsx b/src/modals/borrow/borrow-modal-global.tsx
new file mode 100644
index 00000000..e504b88c
--- /dev/null
+++ b/src/modals/borrow/borrow-modal-global.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useConnection } from 'wagmi';
+import type { Market } from '@/utils/types';
+import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing';
+import { useOraclePrice } from '@/hooks/useOraclePrice';
+import useUserPosition from '@/hooks/useUserPosition';
+import type { SupportedNetworks } from '@/utils/networks';
+import { BorrowModal } from './borrow-modal';
+
+type BorrowModalGlobalProps = {
+ market: Market;
+ defaultMode?: 'borrow' | 'repay';
+ refetch?: () => void;
+ liquiditySourcing?: LiquiditySourcingResult;
+ onOpenChange: (open: boolean) => void;
+};
+
+/**
+ * Global BorrowModal wrapper that fetches oracle price and user position automatically.
+ * Used by the ModalRenderer via the modal registry.
+ */
+export function BorrowModalGlobal({
+ market,
+ defaultMode,
+ refetch: externalRefetch,
+ liquiditySourcing,
+ onOpenChange,
+}: BorrowModalGlobalProps): JSX.Element {
+ const { address } = useConnection();
+ const chainId = market.morphoBlue.chain.id as SupportedNetworks;
+
+ const { price: oraclePrice } = useOraclePrice({
+ oracle: market.oracleAddress as `0x${string}`,
+ chainId,
+ });
+
+ const { position, refetch: refetchPosition } = useUserPosition(address, chainId, market.uniqueKey);
+
+ const handleRefetch = () => {
+ refetchPosition();
+ externalRefetch?.();
+ };
+
+ return (
+
+ );
+}
diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx
index b00dc4de..35d6c008 100644
--- a/src/modals/borrow/borrow-modal.tsx
+++ b/src/modals/borrow/borrow-modal.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { LuArrowRightLeft } from 'react-icons/lu';
import { useConnection, useReadContract, useBalance } from 'wagmi';
import { erc20Abi } from 'viem';
@@ -17,11 +17,26 @@ type BorrowModalProps = {
refetch?: () => void;
isRefreshing?: boolean;
position: MarketPosition | null;
+ defaultMode?: 'borrow' | 'repay';
liquiditySourcing?: LiquiditySourcingResult;
};
-export function BorrowModal({ market, onOpenChange, oraclePrice, refetch, isRefreshing = false, position, liquiditySourcing }: BorrowModalProps): JSX.Element {
- const [mode, setMode] = useState<'borrow' | 'repay'>('borrow');
+export function BorrowModal({
+ market,
+ onOpenChange,
+ oraclePrice,
+ refetch,
+ isRefreshing = false,
+ position,
+ defaultMode = 'borrow',
+ liquiditySourcing,
+}: BorrowModalProps): JSX.Element {
+ const [mode, setMode] = useState<'borrow' | 'repay'>(defaultMode);
+
+ // Reset mode when defaultMode changes (e.g., modal re-opened with different mode)
+ useEffect(() => {
+ setMode(defaultMode);
+ }, [defaultMode]);
const { address: account } = useConnection();
// Get token balances
diff --git a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
index 857de08c..3c9348e7 100644
--- a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
+++ b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx
@@ -189,16 +189,16 @@ export function WithdrawCollateralAndRepay({
-
Total Borrowed
+
Outstanding Debt
-
+
{formatBalance(BigInt(currentPosition?.state.borrowAssets ?? 0), market.loanAsset.decimals)} {market.loanAsset.symbol}
diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx
index 342ab714..90787268 100644
--- a/src/modals/registry.tsx
+++ b/src/modals/registry.tsx
@@ -13,6 +13,9 @@ import { lazy } from 'react';
// Swap
const SwapModal = lazy(() => import('@/features/swap/components/SwapModal').then((m) => ({ default: m.SwapModal })));
+// Borrow & Repay
+const BorrowModalGlobal = lazy(() => import('@/modals/borrow/borrow-modal-global').then((m) => ({ default: m.BorrowModalGlobal })));
+
// Supply & Withdraw
const SupplyModalV2 = lazy(() => import('@/modals/supply/supply-modal').then((m) => ({ default: m.SupplyModalV2 })));
@@ -45,6 +48,7 @@ const VaultWithdrawModal = lazy(() => import('@/modals/vault/vault-withdraw-moda
export const MODAL_REGISTRY: {
[K in ModalType]: ComponentType
;
} = {
+ borrow: BorrowModalGlobal,
bridgeSwap: SwapModal,
supply: SupplyModalV2,
rebalance: RebalanceModal,
diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts
index 92b3e4e1..55ae3d27 100644
--- a/src/stores/useModalStore.ts
+++ b/src/stores/useModalStore.ts
@@ -16,6 +16,14 @@ export type ModalProps = {
defaultTargetToken?: SwapToken;
};
+ // Borrow & Repay
+ borrow: {
+ market: Market;
+ defaultMode?: 'borrow' | 'repay';
+ refetch?: () => void;
+ liquiditySourcing?: LiquiditySourcingResult;
+ };
+
// Supply & Withdraw
supply: {
market: Market;