|
@@ -37,6 +37,12 @@ interface PositionRow {
|
|
|
amount_b: string
|
|
amount_b: string
|
|
|
symbol_a: string
|
|
symbol_a: string
|
|
|
symbol_b: string
|
|
symbol_b: string
|
|
|
|
|
+ pnl_usd: string | null
|
|
|
|
|
+ pnl_percent: string | null
|
|
|
|
|
+ apr: string | null
|
|
|
|
|
+ bonus_usd: string | null
|
|
|
|
|
+ earned_usd: string | null
|
|
|
|
|
+ liquidity_usd: string | null
|
|
|
status: string
|
|
status: string
|
|
|
created_at: string
|
|
created_at: string
|
|
|
}
|
|
}
|
|
@@ -51,6 +57,13 @@ function formatPrice(price: string): string {
|
|
|
return n.toExponential(3)
|
|
return n.toExponential(3)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function formatUsd(value: string | null): string {
|
|
|
|
|
+ if (!value) return '-'
|
|
|
|
|
+ const n = parseFloat(value)
|
|
|
|
|
+ if (isNaN(n)) return value
|
|
|
|
|
+ return n >= 0 ? `$${n.toFixed(2)}` : `-$${Math.abs(n).toFixed(2)}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default function PositionsPage() {
|
|
export default function PositionsPage() {
|
|
|
const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
|
|
const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
|
|
|
const [closingId, setClosingId] = useState<number | null>(null)
|
|
const [closingId, setClosingId] = useState<number | null>(null)
|
|
@@ -58,10 +71,32 @@ export default function PositionsPage() {
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
|
|
const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
|
|
|
const [showClosed, setShowClosed] = useState(false)
|
|
const [showClosed, setShowClosed] = useState(false)
|
|
|
|
|
+ const [syncing, setSyncing] = useState(false)
|
|
|
|
|
+ const [syncResult, setSyncResult] = useState<string | null>(null)
|
|
|
|
|
|
|
|
const allRows: PositionRow[] = positions || []
|
|
const allRows: PositionRow[] = positions || []
|
|
|
const rows = showClosed ? allRows : allRows.filter((r) => r.status !== 'closed')
|
|
const rows = showClosed ? allRows : allRows.filter((r) => r.status !== 'closed')
|
|
|
|
|
|
|
|
|
|
+ const handleSync = async () => {
|
|
|
|
|
+ setSyncing(true)
|
|
|
|
|
+ setSyncResult(null)
|
|
|
|
|
+ setError(null)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/sync', { method: 'POST' })
|
|
|
|
|
+ const data = await res.json()
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ setError(data.error || 'Sync failed')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setSyncResult(`Synced ${data.synced} positions, closed ${data.closed}, API total ${data.total}`)
|
|
|
|
|
+ mutate()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ setError(e instanceof Error ? e.message : 'Sync failed')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSyncing(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const handleClose = async (row: PositionRow) => {
|
|
const handleClose = async (row: PositionRow) => {
|
|
|
setClosingId(row.id)
|
|
setClosingId(row.id)
|
|
|
setError(null)
|
|
setError(null)
|
|
@@ -116,11 +151,16 @@ export default function PositionsPage() {
|
|
|
<div className="space-y-6">
|
|
<div className="space-y-6">
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center justify-between">
|
|
|
<h2 className="text-lg font-semibold">Position Mappings</h2>
|
|
<h2 className="text-lg font-semibold">Position Mappings</h2>
|
|
|
- <div className="flex items-center gap-2">
|
|
|
|
|
- <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
|
|
|
|
|
- <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
|
|
|
|
|
- Show closed
|
|
|
|
|
- </Label>
|
|
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <Button onClick={handleSync} disabled={syncing} variant="outline" size="sm" className="text-xs">
|
|
|
|
|
+ {syncing ? 'Syncing...' : 'Sync from Byreal'}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
|
|
|
|
|
+ <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
|
|
|
|
|
+ Show closed
|
|
|
|
|
+ </Label>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -133,6 +173,15 @@ export default function PositionsPage() {
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
|
|
+ {syncResult && (
|
|
|
|
|
+ <div className="rounded-lg border border-green-500/30 bg-green-500/10 p-3 text-xs text-green-400">
|
|
|
|
|
+ {syncResult}
|
|
|
|
|
+ <button onClick={() => setSyncResult(null)} className="ml-2 underline">
|
|
|
|
|
+ dismiss
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
<Card>
|
|
<Card>
|
|
|
<CardContent className="p-0">
|
|
<CardContent className="p-0">
|
|
|
{rows.length === 0 ? (
|
|
{rows.length === 0 ? (
|
|
@@ -142,116 +191,155 @@ export default function PositionsPage() {
|
|
|
<Table>
|
|
<Table>
|
|
|
<TableHeader>
|
|
<TableHeader>
|
|
|
<TableRow>
|
|
<TableRow>
|
|
|
- <TableHead>Target</TableHead>
|
|
|
|
|
- <TableHead>Target NFT</TableHead>
|
|
|
|
|
- <TableHead>Our NFT</TableHead>
|
|
|
|
|
<TableHead>Pool</TableHead>
|
|
<TableHead>Pool</TableHead>
|
|
|
<TableHead>Size</TableHead>
|
|
<TableHead>Size</TableHead>
|
|
|
|
|
+ <TableHead>PNL</TableHead>
|
|
|
|
|
+ <TableHead>Fee</TableHead>
|
|
|
|
|
+ <TableHead>Bonus</TableHead>
|
|
|
|
|
+ <TableHead>APR</TableHead>
|
|
|
<TableHead>Price Range</TableHead>
|
|
<TableHead>Price Range</TableHead>
|
|
|
|
|
+ <TableHead>Target</TableHead>
|
|
|
|
|
+ <TableHead>Our NFT</TableHead>
|
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
|
<TableHead>Created</TableHead>
|
|
<TableHead>Created</TableHead>
|
|
|
<TableHead>Actions</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
|
</TableRow>
|
|
</TableRow>
|
|
|
</TableHeader>
|
|
</TableHeader>
|
|
|
<TableBody>
|
|
<TableBody>
|
|
|
- {rows.map((row) => (
|
|
|
|
|
- <TableRow key={row.id}>
|
|
|
|
|
- <TableCell className="text-muted-foreground">
|
|
|
|
|
- {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
- <a
|
|
|
|
|
- href={`https://solscan.io/token/${row.target_nft_mint}`}
|
|
|
|
|
- target="_blank"
|
|
|
|
|
- rel="noopener noreferrer"
|
|
|
|
|
- className="text-primary hover:underline"
|
|
|
|
|
- >
|
|
|
|
|
- {row.target_nft_mint.slice(0, 6)}...
|
|
|
|
|
- </a>
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
- {row.our_nft_mint ? (
|
|
|
|
|
|
|
+ {rows.map((row) => {
|
|
|
|
|
+ const pnlNum = parseFloat(row.pnl_usd || '0')
|
|
|
|
|
+ const pnlColor = pnlNum > 0 ? 'text-green-400' : pnlNum < 0 ? 'text-red-400' : 'text-muted-foreground'
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <TableRow key={row.id}>
|
|
|
|
|
+ <TableCell className="text-muted-foreground">
|
|
|
|
|
+ {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {(row.liquidity_usd || row.size_usd) ? (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className="text-foreground font-medium">
|
|
|
|
|
+ {formatUsd(row.liquidity_usd || row.size_usd)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {row.amount_a && row.symbol_a && (
|
|
|
|
|
+ <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
|
|
|
|
|
+ {row.amount_a} {row.symbol_a}
|
|
|
|
|
+ {row.amount_b && ` + ${row.amount_b} ${row.symbol_b}`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-muted-foreground">-</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {row.pnl_usd ? (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span className={`font-medium ${pnlColor}`}>{formatUsd(row.pnl_usd)}</span>
|
|
|
|
|
+ {row.pnl_percent && (
|
|
|
|
|
+ <div className={`text-[10px] ${pnlColor}`}>
|
|
|
|
|
+ {parseFloat(row.pnl_percent) >= 0 ? '+' : ''}{parseFloat(row.pnl_percent).toFixed(2)}%
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-muted-foreground">-</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ <span className={row.earned_usd && parseFloat(row.earned_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
|
|
|
|
|
+ {formatUsd(row.earned_usd)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ <span className={row.bonus_usd && parseFloat(row.bonus_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
|
|
|
|
|
+ {formatUsd(row.bonus_usd)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {row.apr ? (
|
|
|
|
|
+ <span className="text-foreground">{row.apr}%</span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-muted-foreground">-</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell className="text-muted-foreground text-[11px]">
|
|
|
|
|
+ {row.price_lower && row.price_upper
|
|
|
|
|
+ ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
|
|
|
|
|
+ : `${row.tick_lower} ~ ${row.tick_upper}`}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
<a
|
|
<a
|
|
|
- href={`https://solscan.io/token/${row.our_nft_mint}`}
|
|
|
|
|
|
|
+ href={`https://solscan.io/account/${row.target_address}`}
|
|
|
target="_blank"
|
|
target="_blank"
|
|
|
rel="noopener noreferrer"
|
|
rel="noopener noreferrer"
|
|
|
- className="text-primary hover:underline"
|
|
|
|
|
|
|
+ className="text-primary hover:underline text-[11px]"
|
|
|
>
|
|
>
|
|
|
- {row.our_nft_mint.slice(0, 6)}...
|
|
|
|
|
|
|
+ {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
|
|
|
</a>
|
|
</a>
|
|
|
- ) : (
|
|
|
|
|
- <span className="text-muted-foreground">pending</span>
|
|
|
|
|
- )}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell className="text-muted-foreground">
|
|
|
|
|
- {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
- {row.size_usd ? (
|
|
|
|
|
- <div>
|
|
|
|
|
- <span className="text-green-400 font-medium">${row.size_usd}</span>
|
|
|
|
|
- <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
|
|
|
|
|
- {row.amount_a && row.symbol_a && `${row.amount_a} ${row.symbol_a}`}
|
|
|
|
|
- {row.amount_a && row.amount_b && ' + '}
|
|
|
|
|
- {row.amount_b && row.symbol_b && `${row.amount_b} ${row.symbol_b}`}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <span className="text-muted-foreground">-</span>
|
|
|
|
|
- )}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell className="text-muted-foreground">
|
|
|
|
|
- {row.price_lower && row.price_upper
|
|
|
|
|
- ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
|
|
|
|
|
- : `${row.tick_lower} ~ ${row.tick_upper}`}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
- <Badge
|
|
|
|
|
- variant={
|
|
|
|
|
- row.status === 'active'
|
|
|
|
|
- ? 'success'
|
|
|
|
|
- : row.status === 'closed'
|
|
|
|
|
- ? 'secondary'
|
|
|
|
|
- : 'destructive'
|
|
|
|
|
- }
|
|
|
|
|
- >
|
|
|
|
|
- {row.status}
|
|
|
|
|
- </Badge>
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell className="text-muted-foreground">
|
|
|
|
|
- {new Date(row.created_at + 'Z').toLocaleString(undefined, {
|
|
|
|
|
- month: '2-digit',
|
|
|
|
|
- day: '2-digit',
|
|
|
|
|
- hour: '2-digit',
|
|
|
|
|
- minute: '2-digit',
|
|
|
|
|
- })}
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
- <div className="flex gap-1.5">
|
|
|
|
|
- {row.status === 'active' && row.our_nft_mint && (
|
|
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {row.our_nft_mint ? (
|
|
|
|
|
+ <a
|
|
|
|
|
+ href={`https://solscan.io/token/${row.our_nft_mint}`}
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ className="text-primary hover:underline text-[11px]"
|
|
|
|
|
+ >
|
|
|
|
|
+ {row.our_nft_mint.slice(0, 6)}...
|
|
|
|
|
+ </a>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-muted-foreground">pending</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ <Badge
|
|
|
|
|
+ variant={
|
|
|
|
|
+ row.status === 'active'
|
|
|
|
|
+ ? 'success'
|
|
|
|
|
+ : row.status === 'closed'
|
|
|
|
|
+ ? 'secondary'
|
|
|
|
|
+ : 'destructive'
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ {row.status}
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell className="text-muted-foreground">
|
|
|
|
|
+ {new Date(row.created_at + 'Z').toLocaleString(undefined, {
|
|
|
|
|
+ month: '2-digit',
|
|
|
|
|
+ day: '2-digit',
|
|
|
|
|
+ hour: '2-digit',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ })}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ <div className="flex gap-1.5">
|
|
|
|
|
+ {row.status === 'active' && row.our_nft_mint && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setConfirmAction({ type: 'close', row })}
|
|
|
|
|
+ disabled={closingId === row.id}
|
|
|
|
|
+ className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
|
|
|
|
+ >
|
|
|
|
|
+ {closingId === row.id ? 'Closing...' : 'Close'}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
<Button
|
|
<Button
|
|
|
- variant="outline"
|
|
|
|
|
|
|
+ variant="destructive"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => setConfirmAction({ type: 'close', row })}
|
|
|
|
|
- disabled={closingId === row.id}
|
|
|
|
|
- className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
|
|
|
|
|
|
+ onClick={() => setConfirmAction({ type: 'delete', row })}
|
|
|
|
|
+ disabled={deletingId === row.id}
|
|
|
|
|
+ className="text-[10px] h-6 px-2"
|
|
|
>
|
|
>
|
|
|
- {closingId === row.id ? 'Closing...' : 'Close'}
|
|
|
|
|
|
|
+ {deletingId === row.id ? 'Deleting...' : 'Delete'}
|
|
|
</Button>
|
|
</Button>
|
|
|
- )}
|
|
|
|
|
- <Button
|
|
|
|
|
- variant="destructive"
|
|
|
|
|
- size="sm"
|
|
|
|
|
- onClick={() => setConfirmAction({ type: 'delete', row })}
|
|
|
|
|
- disabled={deletingId === row.id}
|
|
|
|
|
- className="text-[10px] h-6 px-2"
|
|
|
|
|
- >
|
|
|
|
|
- {deletingId === row.id ? 'Deleting...' : 'Delete'}
|
|
|
|
|
- </Button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </TableCell>
|
|
|
|
|
- </TableRow>
|
|
|
|
|
- ))}
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ </TableRow>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
</TableBody>
|
|
</TableBody>
|
|
|
</Table>
|
|
</Table>
|
|
|
</div>
|
|
</div>
|