| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- 'use client'
- import { useState } from 'react'
- import useSWR from 'swr'
- import { Card, CardContent } from '@/components/ui/card'
- import { Badge } from '@/components/ui/badge'
- import { Button } from '@/components/ui/button'
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
- import { Switch } from '@/components/ui/switch'
- import { Label } from '@/components/ui/label'
- import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- } from '@/components/ui/alert-dialog'
- const fetcher = (url: string) => fetch(url).then((r) => r.json())
- interface PositionRow {
- id: number
- target_address: string
- target_nft_mint: string
- our_nft_mint: string | null
- pool_id: string
- pool_label: string
- tick_lower: number
- tick_upper: number
- price_lower: string
- price_upper: string
- size_usd: string
- amount_a: string
- amount_b: string
- symbol_a: 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
- created_at: string
- }
- function formatPrice(price: string): string {
- if (!price) return ''
- const n = parseFloat(price)
- if (isNaN(n)) return price
- if (n >= 1000) return n.toFixed(2)
- if (n >= 1) return n.toFixed(4)
- if (n >= 0.0001) return n.toFixed(6)
- 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() {
- const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
- const [closingId, setClosingId] = useState<number | null>(null)
- const [deletingId, setDeletingId] = useState<number | null>(null)
- const [error, setError] = useState<string | null>(null)
- const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
- const [showClosed, setShowClosed] = useState(false)
- const [syncing, setSyncing] = useState(false)
- const [syncResult, setSyncResult] = useState<string | null>(null)
- const allRows: PositionRow[] = positions || []
- 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) => {
- setClosingId(row.id)
- setError(null)
- try {
- const res = await fetch('/api/positions', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: row.id }),
- })
- const data = await res.json()
- if (!res.ok) {
- setError(data.error || 'Close failed')
- } else {
- mutate()
- }
- } catch (e) {
- setError(e instanceof Error ? e.message : 'Close failed')
- } finally {
- setClosingId(null)
- }
- }
- const handleDelete = async (row: PositionRow) => {
- setDeletingId(row.id)
- setError(null)
- try {
- const res = await fetch(`/api/positions?id=${row.id}`, { method: 'DELETE' })
- const data = await res.json()
- if (!res.ok) {
- setError(data.error || 'Delete failed')
- } else {
- mutate()
- }
- } catch (e) {
- setError(e instanceof Error ? e.message : 'Delete failed')
- } finally {
- setDeletingId(null)
- }
- }
- const handleConfirm = () => {
- if (!confirmAction) return
- if (confirmAction.type === 'close') {
- handleClose(confirmAction.row)
- } else {
- handleDelete(confirmAction.row)
- }
- setConfirmAction(null)
- }
- return (
- <div className="space-y-6">
- <div className="flex items-center justify-between">
- <h2 className="text-lg font-semibold">Position Mappings</h2>
- <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>
- {error && (
- <div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-400">
- {error}
- <button onClick={() => setError(null)} className="ml-2 underline">
- dismiss
- </button>
- </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>
- <CardContent className="p-0">
- {rows.length === 0 ? (
- <p className="text-xs text-muted-foreground p-4">No position mappings yet</p>
- ) : (
- <div className="overflow-x-auto">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>Pool</TableHead>
- <TableHead>Size</TableHead>
- <TableHead>PNL</TableHead>
- <TableHead>Fee</TableHead>
- <TableHead>Bonus</TableHead>
- <TableHead>APR</TableHead>
- <TableHead>Price Range</TableHead>
- <TableHead>Target</TableHead>
- <TableHead>Our NFT</TableHead>
- <TableHead>Status</TableHead>
- <TableHead>Created</TableHead>
- <TableHead>Actions</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {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
- href={`https://solscan.io/account/${row.target_address}`}
- target="_blank"
- rel="noopener noreferrer"
- className="text-primary hover:underline text-[11px]"
- >
- {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
- </a>
- </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
- 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>
- )
- })}
- </TableBody>
- </Table>
- </div>
- )}
- </CardContent>
- </Card>
- <AlertDialog open={!!confirmAction} onOpenChange={(open) => !open && setConfirmAction(null)}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>
- {confirmAction?.type === 'close' ? 'Close Position' : 'Delete Position'}
- </AlertDialogTitle>
- <AlertDialogDescription>
- {confirmAction?.type === 'close'
- ? `Close position ${confirmAction.row.our_nft_mint?.slice(0, 8)}...? This will remove all liquidity on-chain and swap tokens back to USDC.`
- : `Delete position mapping #${confirmAction?.row.id}? This only removes the record from the database, not from the blockchain.`}
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel>Cancel</AlertDialogCancel>
- <AlertDialogAction onClick={handleConfirm}>
- {confirmAction?.type === 'close' ? 'Close Position' : 'Delete'}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </div>
- )
- }
|