-
Notifications
You must be signed in to change notification settings - Fork 3
feat: improve borrow/repay discoverability on positions page #336
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7505e2c
85e8d1b
ec12d3b
121e2f5
e9f0165
3a69322
178c09f
d889475
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ( | ||
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| className={cn( | ||
| 'inline-flex items-center gap-1.5 pl-3 pr-2 py-2 text-sm font-medium transition-all duration-200', | ||
| 'rounded-l-sm rounded-r-none', | ||
| 'bg-surface text-foreground hover:brightness-95', | ||
| 'active:scale-[0.98] active:brightness-90', | ||
| )} | ||
| > | ||
| {showIndicator && <span className="h-2 w-2 rounded-full bg-primary block" />} | ||
| {icon} | ||
| {label} | ||
| </button> | ||
| ); | ||
|
|
||
| const wrappedButton = showIndicator && indicator?.tooltip | ||
| ? <Tooltip content={indicator.tooltip}>{mainButton}</Tooltip> | ||
| : mainButton; | ||
|
|
||
| return ( | ||
| <div className={cn( | ||
| 'inline-flex items-stretch shadow-sm', | ||
| 'hover:shadow-md transition-shadow', | ||
| className, | ||
| )}> | ||
| {wrappedButton} | ||
|
|
||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <button | ||
| type="button" | ||
| className={cn( | ||
| 'inline-flex items-center justify-center px-1 transition-all duration-200', | ||
| 'rounded-l-none rounded-r-sm', | ||
| 'bg-surface text-foreground hover:brightness-95', | ||
| 'border-l border-border/20', | ||
| 'active:brightness-90', | ||
| )} | ||
| > | ||
| <ChevronDownIcon className="h-3 w-3 opacity-60" /> | ||
| </button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end" className="min-w-0 p-1"> | ||
| {dropdownItems.map((item) => ( | ||
| <DropdownMenuItem | ||
| key={item.label} | ||
| onClick={item.onClick} | ||
| disabled={item.disabled} | ||
| startContent={item.icon} | ||
| className="px-2 py-2 text-sm gap-1.5" | ||
| > | ||
| {item.label} | ||
| </DropdownMenuItem> | ||
| ))} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ? ( | ||||||||||||||||
| <div className="flex items-center gap-3"> | ||||||||||||||||
| <TokenIcon | ||||||||||||||||
| address={market.loanAsset.address} | ||||||||||||||||
| chainId={market.morphoBlue.chain.id} | ||||||||||||||||
| symbol={market.loanAsset.symbol} | ||||||||||||||||
| width={20} | ||||||||||||||||
| height={20} | ||||||||||||||||
| /> | ||||||||||||||||
| <div> | ||||||||||||||||
| <p className="text-xs text-secondary">Supplied</p> | ||||||||||||||||
| <p className="text-sm font-medium tabular-nums"> | ||||||||||||||||
| {formatReadable(Number(formatUnits(BigInt(userPosition.state.supplyAssets), market.loanAsset.decimals)))}{' '} | ||||||||||||||||
| {market.loanAsset.symbol} | ||||||||||||||||
| </p> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| ) : undefined; | ||||||||||||||||
|
|
||||||||||||||||
| const borrowTooltip = | ||||||||||||||||
| hasBorrowPosition && userPosition ? ( | ||||||||||||||||
| <div className="space-y-2"> | ||||||||||||||||
| {hasCollateral && ( | ||||||||||||||||
| <div className="flex items-center gap-3"> | ||||||||||||||||
| <TokenIcon | ||||||||||||||||
| address={market.collateralAsset.address} | ||||||||||||||||
| chainId={market.morphoBlue.chain.id} | ||||||||||||||||
| symbol={market.collateralAsset.symbol} | ||||||||||||||||
| width={20} | ||||||||||||||||
| height={20} | ||||||||||||||||
| /> | ||||||||||||||||
| <div> | ||||||||||||||||
| <p className="text-xs text-secondary">Collateral</p> | ||||||||||||||||
| <p className="text-sm font-medium tabular-nums"> | ||||||||||||||||
| {formatReadable(Number(formatUnits(BigInt(userPosition.state.collateral), market.collateralAsset.decimals)))}{' '} | ||||||||||||||||
| {market.collateralAsset.symbol} | ||||||||||||||||
| </p> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| )} | ||||||||||||||||
| {hasBorrow && ( | ||||||||||||||||
| <div className="flex items-center gap-3"> | ||||||||||||||||
| <TokenIcon | ||||||||||||||||
| address={market.loanAsset.address} | ||||||||||||||||
| chainId={market.morphoBlue.chain.id} | ||||||||||||||||
| symbol={market.loanAsset.symbol} | ||||||||||||||||
| width={20} | ||||||||||||||||
| height={20} | ||||||||||||||||
| /> | ||||||||||||||||
| <div> | ||||||||||||||||
| <p className="text-xs text-secondary">Borrowed</p> | ||||||||||||||||
| <p className="text-sm font-medium tabular-nums"> | ||||||||||||||||
| {formatReadable(Number(formatUnits(BigInt(userPosition.state.borrowAssets), market.loanAsset.decimals)))}{' '} | ||||||||||||||||
| {market.loanAsset.symbol} | ||||||||||||||||
| </p> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| )} | ||||||||||||||||
| </div> | ||||||||||||||||
| ) : undefined; | ||||||||||||||||
|
|
||||||||||||||||
| return ( | ||||||||||||||||
| <> | ||||||||||||||||
| <SplitActionButton | ||||||||||||||||
| label="Supply" | ||||||||||||||||
| icon={<BsArrowUpCircle className="h-4 w-4" />} | ||||||||||||||||
| onClick={onSupplyClick} | ||||||||||||||||
| indicator={{ show: hasSupply, tooltip: supplyTooltip }} | ||||||||||||||||
| dropdownItems={[ | ||||||||||||||||
| { | ||||||||||||||||
| label: 'Withdraw', | ||||||||||||||||
| icon: <LuArrowDownToLine className="h-4 w-4" />, | ||||||||||||||||
| onClick: onWithdrawClick, | ||||||||||||||||
| disabled: !hasSupply, | ||||||||||||||||
| }, | ||||||||||||||||
| ]} | ||||||||||||||||
| /> | ||||||||||||||||
|
|
||||||||||||||||
| <SplitActionButton | ||||||||||||||||
| label="Borrow" | ||||||||||||||||
| icon={<BsArrowDownLeftCircle className="h-4 w-4" />} | ||||||||||||||||
| onClick={onBorrowClick} | ||||||||||||||||
| indicator={{ show: hasBorrowPosition, tooltip: borrowTooltip }} | ||||||||||||||||
| dropdownItems={[ | ||||||||||||||||
| { | ||||||||||||||||
| label: 'Repay', | ||||||||||||||||
| icon: <LuRefreshCw className="h-4 w-4" />, | ||||||||||||||||
| 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); | ||||||||||||||||
| }; | ||||||||||||||||
|
Comment on lines
+295
to
+297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modal not closed after confirm.
Proposed fix const handleConfirmBlacklist = () => {
addBlacklistedMarket(market.uniqueKey, market.morphoBlue.chain.id);
+ setIsBlacklistModalOpen(false);
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| const handleCopyMarketId = async () => { | ||||||||||||||||
| try { | ||||||||||||||||
|
|
@@ -323,46 +476,43 @@ export function MarketHeader({ | |||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
|
|
||||||||||||||||
| {/* Position Pill + Actions Dropdown */} | ||||||||||||||||
| {/* Action Buttons + Dropdown */} | ||||||||||||||||
| <div className="flex flex-wrap items-center gap-2"> | ||||||||||||||||
| {userPosition && ( | ||||||||||||||||
| <PositionPill | ||||||||||||||||
| position={userPosition} | ||||||||||||||||
| onSupplyClick={onSupplyClick} | ||||||||||||||||
| onBorrowClick={onBorrowClick} | ||||||||||||||||
| /> | ||||||||||||||||
| )} | ||||||||||||||||
| <ActionButtons | ||||||||||||||||
| market={market} | ||||||||||||||||
| userPosition={userPosition} | ||||||||||||||||
| onSupplyClick={onSupplyClick} | ||||||||||||||||
| onWithdrawClick={onWithdrawClick} | ||||||||||||||||
| onBorrowClick={onBorrowClick} | ||||||||||||||||
| onRepayClick={onRepayClick} | ||||||||||||||||
| /> | ||||||||||||||||
|
|
||||||||||||||||
| {/* Advanced Options Dropdown */} | ||||||||||||||||
| <DropdownMenu> | ||||||||||||||||
| <DropdownMenuTrigger asChild> | ||||||||||||||||
| <Button | ||||||||||||||||
| size="xs" | ||||||||||||||||
| variant="surface" | ||||||||||||||||
| className="px-0" | ||||||||||||||||
| variant="ghost" | ||||||||||||||||
| size="icon" | ||||||||||||||||
| className="h-8 w-6 min-w-0" | ||||||||||||||||
| > | ||||||||||||||||
| <IoEllipsisVertical className="h-4 w-4" /> | ||||||||||||||||
| </Button> | ||||||||||||||||
| </DropdownMenuTrigger> | ||||||||||||||||
| <DropdownMenuContent align="end"> | ||||||||||||||||
| <DropdownMenuItem | ||||||||||||||||
| onClick={onSupplyClick} | ||||||||||||||||
| startContent={<BsArrowUpCircle className="h-4 w-4" />} | ||||||||||||||||
| onClick={handleToggleStar} | ||||||||||||||||
| startContent={isStarred ? <GoStarFill className="h-4 w-4 text-yellow-500" /> : <GoStar className="h-4 w-4" />} | ||||||||||||||||
| > | ||||||||||||||||
| Supply | ||||||||||||||||
| {isStarred ? 'Unstar' : 'Star'} | ||||||||||||||||
| </DropdownMenuItem> | ||||||||||||||||
| <DropdownMenuItem | ||||||||||||||||
| onClick={onBorrowClick} | ||||||||||||||||
| startContent={<BsArrowDownLeftCircle className="h-4 w-4" />} | ||||||||||||||||
| onClick={handleBlacklistClick} | ||||||||||||||||
| startContent={<AiOutlineStop className="h-4 w-4" />} | ||||||||||||||||
| className={isBlacklisted(market.uniqueKey) ? 'opacity-50 cursor-not-allowed' : ''} | ||||||||||||||||
| disabled={isBlacklisted(market.uniqueKey)} | ||||||||||||||||
| > | ||||||||||||||||
| Borrow | ||||||||||||||||
| {isBlacklisted(market.uniqueKey) ? 'Blacklisted' : 'Blacklist'} | ||||||||||||||||
| </DropdownMenuItem> | ||||||||||||||||
| {usePublicAllocator && ( | ||||||||||||||||
| <DropdownMenuItem | ||||||||||||||||
| onClick={onPullLiquidity} | ||||||||||||||||
| startContent={<BsArrowRepeat className="h-4 w-4" />} | ||||||||||||||||
| > | ||||||||||||||||
| Source Liquidity | ||||||||||||||||
| </DropdownMenuItem> | ||||||||||||||||
| )} | ||||||||||||||||
| {showDeveloperOptions && ( | ||||||||||||||||
| <DropdownMenuItem | ||||||||||||||||
| onClick={accrueInterest} | ||||||||||||||||
|
|
@@ -516,6 +666,12 @@ export function MarketHeader({ | |||||||||||||||
| </AnimatePresence> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| <BlacklistConfirmationModal | ||||||||||||||||
| isOpen={isBlacklistModalOpen} | ||||||||||||||||
| onOpenChange={setIsBlacklistModalOpen} | ||||||||||||||||
| onConfirm={handleConfirmBlacklist} | ||||||||||||||||
| market={market} | ||||||||||||||||
| /> | ||||||||||||||||
| </div> | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.