diff --git a/app/admin/stats/components/TransactionTableBody.tsx b/app/admin/stats/components/TransactionTableBody.tsx index f465d0d3..bfce381c 100644 --- a/app/admin/stats/components/TransactionTableBody.tsx +++ b/app/admin/stats/components/TransactionTableBody.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Link from 'next/link'; import { formatUnits } from 'viem'; -import { AddressIdentity } from '@/components/common/AddressIdentity'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { MarketIdBadge } from '@/components/MarketIdBadge'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/components/MarketIdentity'; @@ -86,7 +86,11 @@ export function TransactionTableBody({ {/* User Address */} - + {/* Loan Asset */} diff --git a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx index fdd076c5..05f9a390 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/VaultInitializationModal.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { FiZap } from 'react-icons/fi'; import { Address, zeroAddress } from 'viem'; import { Button } from '@/components/common'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import { AllocatorCard } from '@/components/common/AllocatorCard'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; import { Spinner } from '@/components/common/Spinner'; @@ -94,7 +94,7 @@ function AdapterCapStep({
Adapter address - +
Adapter cap (%) @@ -141,14 +141,14 @@ function FinalizeSetupStep({
Adapter {adapterIsReady ? ( - + ) : ( Adapter not detected yet. )}
Morpho registry - +
  • Only Morpho-approved adapters can be enabled after this step.
  • diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx index 0afd1ab0..862c2e8a 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentListItem.tsx @@ -1,6 +1,6 @@ import { Address } from 'viem'; import { AgentIcon } from '@/components/AgentIcon'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import { findAgent } from '@/utils/monarch-agent'; type AgentListItemProps = { @@ -14,7 +14,7 @@ export function AgentListItem({ address }: AgentListItemProps) {
    {agent && {agent.name}} - +
    ); } diff --git a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx index 31e87a7b..84d4b778 100644 --- a/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/components/settings/AgentsTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { Address } from 'viem'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import { Button } from '@/components/common/Button'; import { Spinner } from '@/components/common/Spinner'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; @@ -75,12 +75,7 @@ export function AgentsTab({

    {description}

{normalized ? ( - + ) : ( Not assigned )} @@ -235,11 +230,11 @@ export function AgentsTab({ ) : (
{sentinels.map((address) => ( - ))} diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 8b8c26bd..9e21aa74 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -9,7 +9,7 @@ import { IoRefreshOutline } from 'react-icons/io5'; import { Address } from 'viem'; import { useAccount } from 'wagmi'; import { Button } from '@/components/common'; -import { AddressDisplay } from '@/components/common/AddressDisplay'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import Header from '@/components/layout/header/Header'; import { useVaultPage } from '@/hooks/useVaultPage'; import { getSlicedAddress } from '@/utils/address'; @@ -146,7 +146,11 @@ export default function VaultContent() { )}
- +
- +
- +
diff --git a/docs/Styling.md b/docs/Styling.md index 2f907eee..5b007b21 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -437,6 +437,98 @@ Add an action link (like explorer) in the top-right corner: - Render token avatars with `TokenIcon` (`@/components/TokenIcon`) so chain-specific fallbacks, glyph sizing, and tooltips stay consistent. - Display oracle provenance data with `OracleVendorBadge` (`@/components/OracleVendorBadge`) instead of plain text to benefit from vendor icons, warnings, and tooltips. +### Account Identity Component + +**AccountIdentity** (`@/components/common/AccountIdentity`) +- Unified component for displaying addresses, vault names, and ENS names +- Three variants: `badge`, `compact`, `full` +- All avatars are round by default + +**Variant Behaviors:** + +**Badge** - Minimal inline (no avatar) +- Shows: Vault name → ENS name → Shortened address + +**Compact** - Avatar (16px) wrapped in badge +- Avatar + (Vault name → ENS name → Shortened address) +- Single badge wraps both avatar and text + +**Full** - Horizontal layout with all info +- Avatar (36px) + Address badge + Extra badges (all on one line, centered) +- **Address badge**: Always shows shortened address (e.g., 0x1234...5678), click to copy +- **Extra badges** (shown based on conditions): + - Connected badge (if wallet is connected) + - ENS badge (if `showAddress=true` and no vault name) + - Vault badge (if address is a known vault) + +**Styling Rules:** +- Use `rounded-sm` for badges (not `rounded`) +- Background: `bg-hovered` (or `bg-green-500/10` for connected) +- Text: `font-zen` with `text-secondary` or `text-primary` +- No underscores in variable names +- All avatars are round +- Full variant: all elements centered vertically +- Smooth Framer Motion animations on all interactions + +```tsx +import { AccountIdentity } from '@/components/common/AccountIdentity'; + +// Badge variant - minimal inline (no avatar) + + +// Compact variant - avatar (16px) wrapped in badge background + + +// Full variant - avatar + address + extra info badges + + +// Full variant for vault address + +``` + +**Props:** +- `variant`: `'badge'` | `'compact'` | `'full'` +- `linkTo`: `'explorer'` | `'profile'` | `'none'` +- `showCopy`: Show copy icon at end of badge +- `copyable`: Make entire component clickable to copy +- `showAddress`: Show ENS badge (full variant only) +- `showActions`: Show actions popover on click (default: `true`) + +**Actions Popover (Default Behavior):** + +By default, clicking any AccountIdentity shows a minimal popover with: +1. **Copy Address** - Copies address to clipboard +2. **View Account** - Navigate to positions page +3. **View on Explorer** - Opens Etherscan in new tab + +To disable: `showActions={false}` + +```tsx +// Default - shows actions popover on click + + +// Disable actions (e.g., in dropdown menus) + +``` + ### Market Display Components Use the right component for displaying market information: diff --git a/src/components/Account/AccountWithAvatar.tsx b/src/components/Account/AccountWithAvatar.tsx deleted file mode 100644 index e14e5b71..00000000 --- a/src/components/Account/AccountWithAvatar.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Address } from 'viem'; -import { Avatar } from '@/components/Avatar/Avatar'; -import { getSlicedAddress } from '@/utils/address'; - -type AccountWithAvatarProps = { - address: Address; -}; - -function AccountWithSmallAvatar({ address }: AccountWithAvatarProps) { - return ( -
- - - {getSlicedAddress(address as `0x${string}`)} - -
- ); -} - -export default AccountWithSmallAvatar; diff --git a/src/components/Account/AccountWithENS.tsx b/src/components/Account/AccountWithENS.tsx deleted file mode 100644 index 77ca856e..00000000 --- a/src/components/Account/AccountWithENS.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Address } from 'viem'; -import { Avatar } from '@/components/Avatar/Avatar'; -import { getSlicedAddress } from '@/utils/address'; -import { Name } from '../common/Name'; - -type AccountWithENSProps = { - address: Address; -}; - -function AccountWithENS({ address }: AccountWithENSProps) { - return ( -
- -
-
- -
- - {getSlicedAddress(address)} - -
-
- ); -} - -export default AccountWithENS; diff --git a/src/components/common/AccountActionsPopover.tsx b/src/components/common/AccountActionsPopover.tsx new file mode 100644 index 00000000..f18c1403 --- /dev/null +++ b/src/components/common/AccountActionsPopover.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useState, useCallback, type ReactNode } from 'react'; +import { Popover, PopoverTrigger, PopoverContent } from '@heroui/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { LuCopy, LuUser } from 'react-icons/lu'; +import { SiEthereum } from 'react-icons/si'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { getExplorerURL } from '@/utils/external'; +import { SupportedNetworks } from '@/utils/networks'; +import type { Address } from 'viem'; + +type AccountActionsPopoverProps = { + address: Address; + children: ReactNode; +}; + +/** + * Minimal popover showing account actions: + * - Copy address + * - View account (positions page) + * - View on Etherscan + */ +export function AccountActionsPopover({ + address, + children, +}: AccountActionsPopoverProps) { + const [isOpen, setIsOpen] = useState(false); + const toast = useStyledToast(); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(address); + toast.success('Address copied', `${address.slice(0, 6)}...${address.slice(-4)}`); + setIsOpen(false); + } catch (error) { + console.error('Failed to copy address', error); + } + }, [address, toast]); + + const handleViewAccount = useCallback(() => { + window.location.href = `/positions/${address}`; + setIsOpen(false); + }, [address]); + + const handleViewExplorer = useCallback(() => { + const explorerUrl = getExplorerURL(address, SupportedNetworks.Mainnet); + window.open(explorerUrl, '_blank', 'noopener,noreferrer'); + setIsOpen(false); + }, [address]); + + return ( + + +
{children}
+
+ + + {isOpen && ( + + {/* Copy Address */} + void handleCopy()} + className="flex items-center gap-3 px-4 py-2.5 text-sm text-secondary transition-colors hover:bg-hovered hover:text-primary" + whileHover={{ x: 2 }} + whileTap={{ scale: 0.98 }} + > + + Copy Address + + + {/* View Account */} + + + View Account + + + {/* View on Explorer */} + + + View on Explorer + + + )} + + +
+ ); +} diff --git a/src/components/common/AccountIdentity.tsx b/src/components/common/AccountIdentity.tsx new file mode 100644 index 00000000..f2f728ff --- /dev/null +++ b/src/components/common/AccountIdentity.tsx @@ -0,0 +1,346 @@ +'use client'; + +import { useMemo, useState, useEffect, useCallback } from 'react'; +import clsx from 'clsx'; +import { motion } from 'framer-motion'; +import Link from 'next/link'; +import { FaCircle } from 'react-icons/fa'; +import { LuExternalLink, LuCopy } from 'react-icons/lu'; +import { useAccount, useEnsName } from 'wagmi'; +import { Avatar } from '@/components/Avatar/Avatar'; +import { AccountActionsPopover } from '@/components/common/AccountActionsPopover'; +import { Name } from '@/components/common/Name'; +import { useAddressLabel } from '@/hooks/useAddressLabel'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { getExplorerURL } from '@/utils/external'; +import { SupportedNetworks } from '@/utils/networks'; +import type { Address } from 'viem'; + +type AccountIdentityProps = { + address: Address; + variant?: 'badge' | 'compact' | 'full'; + linkTo?: 'explorer' | 'profile' | 'none'; + copyable?: boolean; + showCopy?: boolean; + showAddress?: boolean; + showActions?: boolean; + className?: string; +}; + +/** + * Unified component for displaying account identities across the app. + * + * Badge & Compact: Show vault name → ENS name → shortened address + * Full: Always show address badge + optional extra badges (connected, ENS, vault) + * + * Variants: + * - badge: Minimal inline badge (no avatar) + * - compact: Avatar (16px) wrapped in badge background + * - full: Avatar (36px) + address badge + extra badges (all centered on one line) + */ +export function AccountIdentity({ + address, + variant = 'badge', + linkTo = 'none', + copyable = false, + showCopy = false, + showAddress = false, + showActions = true, + className, +}: AccountIdentityProps) { + const { address: connectedAddress, isConnected } = useAccount(); + const [mounted, setMounted] = useState(false); + const toast = useStyledToast(); + const { vaultName, shortAddress } = useAddressLabel(address); + const { data: ensName } = useEnsName({ + address: address as `0x${string}`, + chainId: 1, + }); + + useEffect(() => { + setMounted(true); + }, []); + + const isOwner = useMemo(() => { + return mounted && isConnected && address === connectedAddress; + }, [address, connectedAddress, isConnected, mounted]); + + const href = useMemo(() => { + // When showActions is enabled, don't use linkTo - popover handles navigation + if (showActions) return null; + + if (linkTo === 'none') return null; + if (linkTo === 'explorer') { + return getExplorerURL(address as `0x${string}`, SupportedNetworks.Mainnet); + } + if (linkTo === 'profile') { + return `/positions/${address}`; + } + return null; + }, [linkTo, address, showActions]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(address); + toast.success('Address copied', `${address.slice(0, 6)}...${address.slice(-4)}`); + } catch (error) { + console.error('Failed to copy address', error); + } + }, [address, toast]); + + // Badge variant - minimal inline badge (no avatar) + if (variant === 'badge') { + const content = ( + <> + {vaultName ?? } + {linkTo === 'explorer' && } + {showCopy && ( + { + e.preventDefault(); + e.stopPropagation(); + void handleCopy(); + }} + /> + )} + + ); + + const badgeClasses = clsx( + 'inline-flex items-center gap-1 rounded-sm bg-hovered px-2 py-1 font-zen text-xs text-secondary', + copyable && 'cursor-pointer transition-colors hover:brightness-110', + href && + 'no-underline transition-colors hover:bg-gray-300 hover:text-primary hover:no-underline dark:hover:bg-gray-700', + className, + ); + + const badgeElement = href ? ( + + { + if (copyable) { + e.preventDefault(); + void handleCopy(); + } else if (linkTo === 'explorer') { + e.stopPropagation(); + } + }} + > + {content} + + + ) : ( + void handleCopy() : undefined} + style={{ cursor: copyable ? 'pointer' : 'default' }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + transition={{ type: 'spring', stiffness: 400, damping: 25 }} + > + {content} + + ); + + if (showActions) { + return ( + + {badgeElement} + + ); + } + + return badgeElement; + } + + // Compact variant - avatar (16px) wrapped in badge background + if (variant === 'compact') { + const badgeContent = ( + <> + + + {vaultName ?? } + + {linkTo === 'explorer' && } + {showCopy && ( + { + e.preventDefault(); + e.stopPropagation(); + void handleCopy(); + }} + /> + )} + + ); + + const compactClasses = clsx( + 'inline-flex items-center gap-1.5 rounded-sm px-1.5 py-1 font-zen text-xs', + mounted && isOwner ? 'bg-green-500/10 text-green-500' : 'bg-hovered text-secondary', + copyable && 'cursor-pointer transition-colors hover:brightness-110', + href && + 'no-underline transition-colors hover:bg-gray-300 hover:text-primary hover:no-underline dark:hover:bg-gray-700', + className, + ); + + const compactElement = href ? ( + + { + if (copyable) { + e.preventDefault(); + void handleCopy(); + } else if (linkTo === 'explorer') { + e.stopPropagation(); + } + }} + > + {badgeContent} + + + ) : ( + void handleCopy() : undefined} + style={{ cursor: copyable ? 'pointer' : 'default' }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + transition={{ type: 'spring', stiffness: 400, damping: 25 }} + > + {badgeContent} + + ); + + if (showActions) { + return ( + + {compactElement} + + ); + } + + return compactElement; + } + + // Full variant - avatar + address badge + extra info badges (all on one line, centered) + const fullContent = ( + <> + + + {/* Address badge - always shows shortened address, click to copy */} + { + e.preventDefault(); + e.stopPropagation(); + void handleCopy(); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + void handleCopy(); + } + }} + > + {shortAddress} + + + + {/* Connected indicator badge */} + {mounted && isOwner && ( + + + Connected + + )} + + {/* ENS badge (only show if there's an actual ENS name) */} + {showAddress && !vaultName && ensName && ( + + {ensName} + + )} + + {/* Vault name badge (if it's a vault) */} + {vaultName && ( + + {vaultName} + + )} + + {/* Explorer link */} + {linkTo === 'explorer' && href && ( + e.stopPropagation()} + > + + + )} + + ); + + const fullClasses = clsx( + 'flex items-center gap-2', + copyable && 'cursor-pointer transition-colors hover:brightness-110', + className, + ); + + const fullElement = + href && linkTo === 'profile' ? ( + + + {fullContent} + + + ) : ( + void handleCopy() : undefined} + style={{ cursor: copyable ? 'pointer' : 'default' }} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + transition={{ type: 'spring', stiffness: 400, damping: 25 }} + > + {fullContent} + + ); + + if (showActions) { + return ( + + {fullElement} + + ); + } + + return fullElement; +} diff --git a/src/components/common/AddressDisplay.tsx b/src/components/common/AddressDisplay.tsx deleted file mode 100644 index 6b25beec..00000000 --- a/src/components/common/AddressDisplay.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import { useMemo, useState, useEffect, useCallback, HTMLAttributes } from 'react'; -import clsx from 'clsx'; -import { FaCircle } from 'react-icons/fa'; -import { LuExternalLink } from 'react-icons/lu'; -import { Address } from 'viem'; -import { useAccount } from 'wagmi'; -import { Avatar } from '@/components/Avatar/Avatar'; -import { Name } from '@/components/common/Name'; -import { useStyledToast } from '@/hooks/useStyledToast'; -import { getExplorerURL } from '@/utils/external'; -import { SupportedNetworks } from '@/utils/networks'; - -type AddressDisplayProps = { - address: Address; - chainId?: SupportedNetworks | number; - size?: 'md' | 'sm'; - showExplorerLink?: boolean; - className?: string; - copyable?: boolean; -}; - -export function AddressDisplay({ - address, - chainId, - size = 'md', - showExplorerLink = false, - className, - copyable = false, -}: AddressDisplayProps) { - const { address: connectedAddress, isConnected } = useAccount(); - const [mounted, setMounted] = useState(false); - const { success: toastSuccess } = useStyledToast(); - - useEffect(() => { - setMounted(true); - }, []); - - const isOwner = useMemo(() => { - return address === connectedAddress; - }, [address, connectedAddress]); - - const explorerHref = useMemo(() => { - if (!showExplorerLink) return null; - const numericChainId = Number(chainId ?? 1); - if (!Number.isFinite(numericChainId)) return null; - return getExplorerURL(address as `0x${string}`, numericChainId as SupportedNetworks); - }, [address, chainId, showExplorerLink]); - - const handleCopy = useCallback(async () => { - if (!copyable) return; - - try { - await navigator.clipboard.writeText(address); - toastSuccess('Address copied', `${address.slice(0, 6)}...${address.slice(-4)}`); - } catch (error) { - console.error('Failed to copy address', error); - } - }, [address, copyable, toastSuccess]); - - const handleKeyDown = useCallback['onKeyDown']>>( - (event) => { - if (!copyable) return; - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - void handleCopy(); - } - }, - [copyable, handleCopy], - ); - - // Only add interactive props when copyable=true to satisfy a11y lint rules - const interactiveProps: HTMLAttributes = copyable - ? { - role: 'button', - tabIndex: 0, - onClick: () => void handleCopy(), - onKeyDown: handleKeyDown, - } - : {}; - - if (size === 'sm') { - return ( -
- - {explorerHref && ( - event.stopPropagation()} - > - - - )} -
- ); - } - - return ( -
-
- - {mounted && isOwner && isConnected && ( -
-
- -
-
- )} -
-
-
- - {explorerHref && ( - event.stopPropagation()} - > - - - )} -
-
-
- ); -} diff --git a/src/components/common/AddressIdentity.tsx b/src/components/common/AddressIdentity.tsx deleted file mode 100644 index 88e8ceda..00000000 --- a/src/components/common/AddressIdentity.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { ExternalLinkIcon } from '@radix-ui/react-icons'; -import Link from 'next/link'; -import { Address } from 'viem'; -import { Name } from '@/components/common/Name'; -import { getExplorerURL } from '@/utils/external'; -import { SupportedNetworks } from '@/utils/networks'; - -type AddressIdentityProps = { - address: Address; - chainId: SupportedNetworks; - showExplorerLink?: boolean; - className?: string; -}; - -export function AddressIdentity({ - address, - chainId, - showExplorerLink = true, - className = '', -}: AddressIdentityProps) { - const explorerHref = useMemo(() => { - return getExplorerURL(address as `0x${string}`, chainId); - }, [address, chainId]); - - if (!showExplorerLink) { - return ( -
- -
- ); - } - - return ( - e.stopPropagation()} - > - - - - ); -} diff --git a/src/components/common/AllocatorCard.tsx b/src/components/common/AllocatorCard.tsx index 54c05bd4..49977bbf 100644 --- a/src/components/common/AllocatorCard.tsx +++ b/src/components/common/AllocatorCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Address } from 'viem'; -import { AddressDisplay } from './AddressDisplay'; +import { AccountIdentity } from './AccountIdentity'; type AllocatorCardProps = { name: string; @@ -50,7 +50,7 @@ export function AllocatorCard({ )}
- +

{description}

diff --git a/src/components/common/index.ts b/src/components/common/index.ts index b61f8abb..8d412f78 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,3 +1,4 @@ export * from './Button'; export * from './TransactionIdentity'; -export * from './AddressIdentity'; +export * from './AccountIdentity'; +export * from './AccountActionsPopover'; diff --git a/src/components/layout/header/AccountDropdown.tsx b/src/components/layout/header/AccountDropdown.tsx index 82acfca3..7b4440c6 100644 --- a/src/components/layout/header/AccountDropdown.tsx +++ b/src/components/layout/header/AccountDropdown.tsx @@ -5,8 +5,8 @@ import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/r import { ExitIcon, ExternalLinkIcon, CopyIcon } from '@radix-ui/react-icons'; import { clsx } from 'clsx'; import { useAccount, useDisconnect } from 'wagmi'; -import AccountWithENS from '@/components/Account/AccountWithENS'; import { Avatar } from '@/components/Avatar/Avatar'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; import { useStyledToast } from '@/hooks/useStyledToast'; import { getExplorerURL } from '@/utils/external'; @@ -59,8 +59,9 @@ export function AccountDropdown() { isReadOnly showDivider={false} > -
- +
+ +
diff --git a/src/contexts/VaultRegistryContext.tsx b/src/contexts/VaultRegistryContext.tsx new file mode 100644 index 00000000..6a42bfcc --- /dev/null +++ b/src/contexts/VaultRegistryContext.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import type { MorphoVault } from '@/data-sources/morpho-api/vaults'; +import { useAllMorphoVaults } from '@/hooks/useAllMorphoVaults'; +import type { Address } from 'viem'; + +type VaultRegistryContextType = { + vaults: MorphoVault[]; + loading: boolean; + error: Error | null; + getVaultByAddress: (address: Address, chainId?: number) => MorphoVault | undefined; +}; + +const VaultRegistryContext = createContext(undefined); + +export function VaultRegistryProvider({ children }: { children: ReactNode }) { + const { vaults, loading, error } = useAllMorphoVaults(); + + const getVaultByAddress = useMemo( + () => (address: Address, chainId?: number) => { + const normalizedAddress = address.toLowerCase(); + return vaults.find( + (v) => + v.address.toLowerCase() === normalizedAddress && (!chainId || v.chainId === chainId), + ); + }, + [vaults], + ); + + const value = useMemo( + () => ({ + vaults, + loading, + error, + getVaultByAddress, + }), + [vaults, loading, error, getVaultByAddress], + ); + + return {children}; +} + +export function useVaultRegistry() { + const context = useContext(VaultRegistryContext); + if (context === undefined) { + throw new Error('useVaultRegistry must be used within a VaultRegistryProvider'); + } + return context; +} diff --git a/src/hooks/useAddressLabel.ts b/src/hooks/useAddressLabel.ts new file mode 100644 index 00000000..6064cdb5 --- /dev/null +++ b/src/hooks/useAddressLabel.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { getSlicedAddress } from '@/utils/address'; +import type { Address } from 'viem'; + +type UseAddressLabelReturn = { + vaultName: string | undefined; + shortAddress: string; +}; + +/** + * Hook to resolve address labels in priority order: + * 1. Vault name (if address is a known vault) + * 2. ENS name (handled by Name component) + * 3. Shortened address (0x1234...5678) + */ +export function useAddressLabel(address: Address, chainId?: number): UseAddressLabelReturn { + const { getVaultByAddress } = useVaultRegistry(); + + const vaultName = useMemo(() => { + const vault = getVaultByAddress(address, chainId); + return vault?.name; + }, [address, chainId, getVaultByAddress]); + + const shortAddress = useMemo(() => { + return getSlicedAddress(address as `0x${string}`); + }, [address]); + + return { + vaultName, + shortAddress, + }; +}