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;