Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ src/modals/
/rebalance/
/preview/
/onboarding/
positions-summary-table.tsx
supplied-morpho-blue-grouped-table.tsx
collateral-icons-display.tsx
...
/autovault/
autovault-view.tsx
Expand Down
117 changes: 117 additions & 0 deletions docs/Styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,76 @@ import { TablePagination } from '@/components/common/TablePagination';

**Styling:** All styling applied via `app/global.css` - don't add inline styles or override padding.

### TableContainerWithHeader Component

**TableContainerWithHeader** (`@/components/common/table-container-with-header`)
- Standard wrapper for tables with header section
- Provides title on left, optional actions on right
- Consistent styling with border separator, overflow handling, and responsive layout

**Structure:**
- Outer wrapper with `bg-surface`, `rounded-md`, `font-zen`, `shadow-sm`
- Header section with border bottom separator
- Title uses `font-monospace text-xs uppercase text-secondary`
- Actions area with flexbox gap spacing
- Overflow-x-auto wrapper for table content

**Props:**
- `title`: Table heading (e.g., "Asset Activity", "Supplied Positions")
- `actions?`: Optional React node for controls (filters, refresh, settings dropdowns)
- `children`: Table component
- `className?`: Additional classes for outer wrapper

**Usage Examples:**

```tsx
import { TableContainerWithHeader } from '@/components/common/table-container-with-header';
import { Table, TableHeader, TableBody } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { DropdownMenu } from '@/components/ui/dropdown-menu';

// Simple table with title only
<TableContainerWithHeader title="Asset Activity">
<Table>
{/* table content */}
</Table>
</TableContainerWithHeader>

// Table with actions (filters, refresh, settings)
<TableContainerWithHeader
title="Supplied Positions"
actions={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon className="mr-2 h-4 w-4" />
Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{/* filter options */}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" onClick={handleRefresh}>
<RefreshIcon className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm">
<SettingsIcon className="h-3 w-3" />
</Button>
</>
}
>
<Table>
{/* table content */}
</Table>
</TableContainerWithHeader>
```

**Examples in codebase:**
- `src/features/admin/components/asset-metrics-table.tsx`
- `src/features/positions/components/supplied-morpho-blue-grouped-table.tsx`

### TablePagination Component

**TablePagination** (`@/components/common/TablePagination`)
Expand Down Expand Up @@ -696,6 +766,53 @@ import { TransactionIdentity } from '@/components/common/TransactionIdentity';
<TransactionIdentity txHash={txHash} chainId={chainId} showFullHash />
```

### CollateralIconsDisplay

Use `CollateralIconsDisplay` from `@/features/positions/components/collateral-icons-display` for displaying collateral tokens with smart overflow handling.

**Features:**
- Shows up to `maxDisplay` collateral icons (default: 8)
- Overlapping icon style with proper z-index stacking
- Automatic sorting by amount (descending)
- "+X more" badge for additional collaterals with tooltip
- Opacity support for collaterals with zero balance

**Props:**
- `collaterals`: Array of collateral objects with `address`, `symbol`, `amount`
- `chainId`: Network chain ID for token icons
- `maxDisplay?`: Maximum icons to display before showing "+X more" badge (default: 8)
- `iconSize?`: Size of token icons in pixels (default: 20)

**Usage:**

```tsx
import { CollateralIconsDisplay } from '@/features/positions/components/collateral-icons-display';

// Basic usage
<CollateralIconsDisplay
collaterals={position.collaterals}
chainId={position.chainId}
/>

// Custom display limit and icon size
<CollateralIconsDisplay
collaterals={position.collaterals}
chainId={position.chainId}
maxDisplay={5}
iconSize={24}
/>
```

**Pattern:**
This component follows the same overlapping icon pattern as `TrustedByCell` in `trusted-vault-badges.tsx`:
- First icon has `ml-0`, subsequent icons have `-ml-2` for overlapping effect
- Z-index decreases from left to right for proper stacking
- "+X more" badge shows remaining items in tooltip
- Empty state shows "No known collaterals" message

**Examples in codebase:**
- `src/features/positions/components/supplied-morpho-blue-grouped-table.tsx`

## Input Components

The codebase uses two different input approaches depending on the use case:
Expand Down
42 changes: 42 additions & 0 deletions src/components/common/table-container-with-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type TableContainerWithHeaderProps = {
title: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
};

/**
* Standard table container with header section.
*
* Provides consistent styling for tables with:
* - Title on the left (uppercase, monospace font)
* - Optional actions on the right (filters, refresh, settings, etc.)
* - Separator border between header and content
* - Responsive overflow handling
*
* @example
* <TableContainerWithHeader
* title="Asset Activity"
* actions={
* <>
* <Button variant="ghost" size="sm">
* <RefreshIcon />
* </Button>
* <DropdownMenu>...</DropdownMenu>
* </>
* }
* >
* <Table>...</Table>
* </TableContainerWithHeader>
*/
export function TableContainerWithHeader({ title, actions, children, className = '' }: TableContainerWithHeaderProps) {
return (
<div className={`bg-surface rounded-md font-zen shadow-sm ${className}`}>
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-800 px-6 py-0.5">
<h3 className="font-monospace text-xs uppercase text-secondary">{title}</h3>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
<div className="overflow-x-auto">{children}</div>
</div>
);
}
9 changes: 9 additions & 0 deletions src/components/layout/header/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FaRegMoon } from 'react-icons/fa';
import { FiSettings } from 'react-icons/fi';
import { LuSunMedium } from 'react-icons/lu';
import { RiBookLine, RiDiscordFill, RiGithubFill } from 'react-icons/ri';
import { TbReport } from 'react-icons/tb';
import { useConnection } from 'wagmi';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { EXTERNAL_LINKS } from '@/utils/external';
Expand Down Expand Up @@ -157,6 +158,14 @@ export function Navbar() {
>
GitHub
</DropdownMenuItem>
{mounted && address && (
<DropdownMenuItem
endContent={<TbReport className="h-4 w-4" />}
onClick={() => router.push(`/positions/report/${address}`)}
>
Report
</DropdownMenuItem>
)}
<DropdownMenuItem
endContent={mounted && (theme === 'dark' ? <LuSunMedium size={16} /> : <FaRegMoon size={14} />)}
onClick={toggleTheme}
Expand Down
18 changes: 12 additions & 6 deletions src/components/ui/icon-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,13 @@ export function IconSwitch({
const isControlled = controlledSelected !== undefined;
const isSelected = isControlled ? controlledSelected : internalSelected;

// Determine which icon to use (null means no icon)
// Determine which icon to use (null/undefined means no icon)
const IconComponent = thumbIconOn && thumbIconOff ? (isSelected ? thumbIconOn : thumbIconOff) : ThumbIcon;

// Use compact config for plain switches, icon config otherwise
const config = IconComponent ? SIZE_CONFIG_WITH_ICON[size] : SIZE_CONFIG_PLAIN[size];
// Treat undefined the same as null - no icon
const hasIcon = IconComponent !== null && IconComponent !== undefined;
const config = hasIcon ? SIZE_CONFIG_WITH_ICON[size] : SIZE_CONFIG_PLAIN[size];
const translate = config.width - config.thumbWidth - config.padding * 2;

const handleToggle = useCallback(() => {
Expand Down Expand Up @@ -202,7 +204,7 @@ export function IconSwitch({
onKeyDown={handleKeyDown}
className={cn(
'relative inline-flex shrink-0 items-center justify-start overflow-hidden rounded-[8px] transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
IconComponent && 'ring-1 ring-[var(--color-background-secondary)]',
hasIcon && 'ring-1 ring-[var(--color-background-secondary)]',
isSelected ? TRACK_COLOR[color] : 'bg-main',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
classNames?.base,
Expand All @@ -215,7 +217,7 @@ export function IconSwitch({
<motion.div
className={cn(
'flex items-center justify-center bg-surface shadow-sm',
IconComponent && 'ring-1 ring-[var(--color-background-secondary)]',
hasIcon && 'ring-1 ring-[var(--color-background-secondary)]',
classNames?.thumb,
)}
initial={false}
Expand All @@ -229,7 +231,7 @@ export function IconSwitch({
}}
style={thumbStyle}
>
{IconComponent && (
{hasIcon && IconComponent && (
<motion.div
initial={false}
className={cn(
Expand All @@ -239,7 +241,11 @@ export function IconSwitch({
classNames?.thumbIcon,
)}
>
<IconComponent className="h-[100%]" isSelected={isSelected} />
{thumbIconOn && thumbIconOff ? (
<IconComponent className="h-[100%]" />
) : (
<IconComponent className="h-[100%]" isSelected={isSelected} />
)}
</motion.div>
)}
</motion.div>
Expand Down
88 changes: 88 additions & 0 deletions src/data-sources/morpho-api/prices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { assetPricesQuery } from '@/graphql/morpho-api-queries';
import { morphoGraphqlFetcher } from './fetchers';

// Type for token price input
export type TokenPriceInput = {
address: string;
chainId: number;
};

// Type for asset price response from Morpho API
type AssetPriceItem = {
address: string;
symbol: string;
decimals: number;
chain: {
id: number;
};
priceUsd: number | null;
};

type AssetPricesResponse = {
data: {
assets: {
items: AssetPriceItem[];
};
};
};

// Create a unique key for token prices
export const getTokenPriceKey = (address: string, chainId: number): string => {
return `${address.toLowerCase()}-${chainId}`;
};

/**
* Fetches token prices from Morpho API for a list of tokens
* @param tokens - Array of token addresses and chain IDs
* @returns Map of token prices keyed by address-chainId
*/
export const fetchTokenPrices = async (tokens: TokenPriceInput[]): Promise<Map<string, number>> => {
if (tokens.length === 0) {
return new Map();
}

// Group tokens by chain for efficient querying
const tokensByChain = new Map<number, string[]>();
tokens.forEach((token) => {
const existing = tokensByChain.get(token.chainId) ?? [];
// Deduplicate and lowercase addresses
const normalizedAddress = token.address.toLowerCase();
if (!existing.includes(normalizedAddress)) {
existing.push(normalizedAddress);
}
tokensByChain.set(token.chainId, existing);
});

// Fetch prices for all chains in parallel
const priceMap = new Map<string, number>();

await Promise.all(
Array.from(tokensByChain.entries()).map(async ([chainId, addresses]) => {
try {
const response = await morphoGraphqlFetcher<AssetPricesResponse>(assetPricesQuery, {
where: {
address_in: addresses,
chainId_in: [chainId],
},
});

if (!response.data?.assets?.items) {
console.warn(`No price data returned for chain ${chainId}`);
return;
}

// Process each asset and add to price map
response.data.assets.items.forEach((asset) => {
if (asset.priceUsd !== null) {
const key = getTokenPriceKey(asset.address, asset.chain.id);
priceMap.set(key, asset.priceUsd);
}
});
} catch (error) {
console.error(`Failed to fetch prices for chain ${chainId}:`, error);
}
}),
);

return priceMap;
};
4 changes: 3 additions & 1 deletion src/data-sources/subgraph/v2-vaults.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Address } from 'viem';
import type { VaultV2Details } from '@/data-sources/morpho-api/v2-vaults';
import { userVaultsV2AddressesQuery } from '@/graphql/morpho-v2-subgraph-queries';
import { type SupportedNetworks, getAgentConfig, networks, isAgentAvailable } from '@/utils/networks';
Expand Down Expand Up @@ -26,7 +27,8 @@ export type UserVaultV2Address = {
// This is used by the autovault page to display user's vaults
export type UserVaultV2 = VaultV2Details & {
networkId: SupportedNetworks;
balance?: bigint;
balance?: bigint; // User's redeemable assets (from previewRedeem)
adapter?: Address; // MorphoMarketV1Adapter address
};

/**
Expand Down
Loading