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
25 changes: 4 additions & 21 deletions app/positions/components/ApyPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,15 @@
import React from 'react';
import { formatReadable } from '@/utils/balance';
import { MetricPreview } from './MetricPreview';

type ApyPreviewProps = {
currentApy: number;
previewApy?: number | null;
};

/**
* Standardized APY preview component.
* Shows current APY in a fixed position, and appends preview to the right when available.
* This ensures perfect vertical alignment across all uses.
* APY preview component.
* Thin wrapper around MetricPreview for APY-specific usage.
*/
export function ApyPreview({ currentApy, previewApy }: ApyPreviewProps) {
const formattedCurrent = formatReadable(currentApy * 100);
const formattedPreview = previewApy ? formatReadable(previewApy * 100) : null;
const hasPreview = Boolean(previewApy && formattedPreview);

const currentClasses = `text-foreground${hasPreview ? ' line-through opacity-50' : ''}`;

return (
<span className="whitespace-nowrap text-sm font-semibold">
<span className={currentClasses}>{formattedCurrent}%</span>
{hasPreview && (
<>
{' → '}
<span className="text-foreground">{formattedPreview}%</span>
</>
)}
</span>
);
return <MetricPreview currentValue={currentApy} previewValue={previewApy} label="APY" />;
}
41 changes: 33 additions & 8 deletions app/positions/components/FromMarketsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '@/compo
import { formatReadable } from '@/utils/balance';
import { previewMarketState } from '@/utils/morpho';
import { MarketPosition } from '@/utils/types';
import { ApyPreview } from './ApyPreview';

type PositionWithPendingDelta = MarketPosition & { pendingDelta: number };

Expand Down Expand Up @@ -52,13 +51,15 @@ export function FromMarketsTable({
<table className="w-full table-fixed rounded-sm font-zen text-sm">
<colgroup>
<col className="w-auto" />
<col className="w-[165px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[220px]" />
</colgroup>
<thead className="table-header bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-2 text-left">Market</th>
<th className="px-4 py-2 text-left">APY</th>
<th className="px-4 py-2 text-right">APY</th>
<th className="px-4 py-2 text-right">Util</th>
<th className="px-4 py-2 text-left">Supplied Amount</th>
</tr>
</thead>
Expand Down Expand Up @@ -101,11 +102,35 @@ export function FromMarketsTable({
/>
</div>
</td>
<td className="px-4 py-2">
<ApyPreview
currentApy={position.market.state.supplyApy}
previewApy={apyPreview?.supplyApy ?? null}
/>
<td className="px-4 py-2 text-right">
{apyPreview ? (
<span className="whitespace-nowrap text-sm text-foreground">
<span className="line-through opacity-50">
{formatReadable(position.market.state.supplyApy * 100)}%
</span>
{' → '}
<span>{formatReadable(apyPreview.supplyApy * 100)}%</span>
</span>
) : (
<span className="whitespace-nowrap text-sm text-foreground">
{formatReadable(position.market.state.supplyApy * 100)}%
</span>
)}
</td>
<td className="px-4 py-2 text-right">
{apyPreview ? (
<span className="whitespace-nowrap text-sm text-foreground">
<span className="line-through opacity-50">
{formatReadable(position.market.state.utilization * 100)}%
</span>
{' → '}
<span>{formatReadable(apyPreview.utilization * 100)}%</span>
</span>
) : (
<span className="whitespace-nowrap text-sm text-foreground">
{formatReadable(position.market.state.utilization * 100)}%
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2 justify-end">
Expand Down
51 changes: 51 additions & 0 deletions app/positions/components/MetricPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { Tooltip } from '@heroui/react';
import { TooltipContent } from '@/components/TooltipContent';
import { formatReadable } from '@/utils/balance';

type MetricPreviewProps = {
currentValue: number;
previewValue?: number | null;
label: string;
};

/**
* Generic metric preview component.
* Shows preview value if available, otherwise shows current value.
* Displays tooltip on hover showing before/after values.
*/
export function MetricPreview({ currentValue, previewValue, label }: MetricPreviewProps) {
const formattedCurrent = formatReadable(currentValue * 100);
const formattedPreview = previewValue ? formatReadable(previewValue * 100) : null;
Comment thread
antoncoding marked this conversation as resolved.
const hasPreview = Boolean(previewValue && formattedPreview);
Comment thread
antoncoding marked this conversation as resolved.

// Show preview value if available, otherwise show current value
const displayValue = hasPreview ? formattedPreview : formattedCurrent;

if (!hasPreview) {
return (
<span className="inline-block min-w-[60px] whitespace-nowrap text-right text-sm text-foreground">
{displayValue}%
</span>
);
}

return (
<Tooltip
classNames={{
base: 'p-0 m-0 bg-transparent shadow-sm border-none',
content: 'p-0 m-0 bg-transparent shadow-sm border-none',
}}
content={
<TooltipContent
title={`${label} Change`}
detail={`${formattedCurrent}% → ${formattedPreview}%`}
/>
}
>
<span className="inline-block min-w-[60px] cursor-help whitespace-nowrap border-b border-dashed border-gray-400 text-right text-sm text-foreground">
{displayValue}%
</span>
</Tooltip>
);
}
139 changes: 14 additions & 125 deletions app/positions/components/RebalanceActionInput.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import React, { useMemo } from 'react';
import { ArrowRightIcon, Cross2Icon } from '@radix-ui/react-icons';
import { parseUnits } from 'viem';
import { Button } from '@/components/common';
import { MarketIdentity, MarketIdentityMode } from '@/components/MarketIdentity';
import { TokenIcon } from '@/components/TokenIcon';
import { previewMarketState } from '@/utils/morpho';
import React from 'react';
import { GroupedPosition, Market } from '@/utils/types';
import { ApyPreview } from './ApyPreview';
import { RebalanceActionRow } from './RebalanceActionRow';

type RebalanceActionInputProps = {
amount: string;
Expand Down Expand Up @@ -43,129 +37,24 @@ export function RebalanceActionInput({
(m) => m.uniqueKey === selectedToMarketUniqueKey,
);

// Calculate preview APY for the selected "to" market
const previewState = useMemo(() => {
if (!selectedToMarket || !amount || Number(amount) <= 0) {
return null;
}
try {
const amountBigInt = parseUnits(amount, groupedPosition.loanAssetDecimals);
return previewMarketState(selectedToMarket, amountBigInt, undefined);
} catch {
return null;
}
}, [selectedToMarket, amount, groupedPosition.loanAssetDecimals]);

return (
<div className="mb-4 rounded-sm border border-gray-200 bg-gray-50/50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div className="mb-2 flex items-center gap-2 text-xs text-secondary">
<span>Add Rebalance Action</span>
</div>

<div className="flex flex-wrap items-center gap-y-4">
{/* Column 1: From → To Market Section */}
<div className="flex min-w-[340px] flex-1 items-center gap-3 pr-8">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary">From</span>
<div
className={`bg-hovered min-w-[140px] rounded-sm border border-gray-200 px-2 py-1.5 dark:border-gray-700 ${
selectedFromMarket ? '' : 'border-dashed opacity-60'
}`}
>
{selectedFromMarket ? (
<MarketIdentity
market={selectedFromMarket}
chainId={selectedFromMarket.morphoBlue.chain.id}
mode={MarketIdentityMode.Badge}
/>
) : (
<span className="text-xs text-secondary">Select above...</span>
)}
</div>
</div>

<ArrowRightIcon className="h-4 w-4 text-secondary" />

<div className="flex items-center gap-2">
<span className="text-xs text-secondary">To</span>
<div className="relative">
<button
type="button"
onClick={onToMarketClick}
className="bg-hovered min-w-[140px] rounded-sm border border-dashed border-gray-200 px-2 py-1.5 text-left transition-colors hover:border-primary hover:bg-primary/5 dark:border-gray-700 dark:hover:border-primary"
>
{selectedToMarket ? (
<MarketIdentity
market={selectedToMarket}
chainId={selectedToMarket.morphoBlue.chain.id}
mode={MarketIdentityMode.Badge}
/>
) : (
<span className="text-xs text-secondary">Click to select...</span>
)}
</button>
{selectedToMarket && onClearToMarket && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClearToMarket();
}}
className="absolute -right-2 -top-2 rounded-full bg-red-500/10 p-0.5 text-red-500 transition-colors hover:bg-red-500/20"
aria-label="Clear selection"
>
<Cross2Icon className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>

{/* Column 2: APY Preview */}
<div className="flex w-[160px] min-w-[160px] flex-col gap-0.5 pl-6 text-xs">
<span className="text-secondary">Market APY</span>
{selectedToMarket ? (
<ApyPreview
currentApy={selectedToMarket.state.supplyApy}
previewApy={previewState?.supplyApy ?? null}
/>
) : (
<span className="text-sm">--</span>
)}
</div>

{/* Column 3: Amount Input + Button */}
<div className="flex w-[220px] min-w-[220px] items-center justify-end gap-2 pl-2">
<div className="bg-hovered relative h-8 rounded-sm">
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="h-full w-32 rounded-sm bg-transparent px-2 pr-8 text-right text-sm focus:border-primary focus:outline-none"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<TokenIcon
address={groupedPosition.loanAssetAddress}
chainId={groupedPosition.chainId}
symbol={groupedPosition.loanAssetSymbol}
width={16}
height={16}
/>
</div>
</div>

<Button
onPress={onAddAction}
variant="cta"
size="sm"
isDisabled={!amount || !selectedFromMarketUniqueKey || !selectedToMarketUniqueKey}
className="h-8 w-[64px]"
>
Add
</Button>
</div>
</div>
<RebalanceActionRow
mode="input"
fromMarket={selectedFromMarket}
toMarket={selectedToMarket}
amount={amount}
groupedPosition={groupedPosition}
onAmountChange={setAmount}
onToMarketClick={onToMarketClick}
onClearToMarket={onClearToMarket}
onAddAction={onAddAction}
isAddDisabled={!amount || !selectedFromMarketUniqueKey || !selectedToMarketUniqueKey}
/>
</div>
);
}
Loading