page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. 'use client'
  2. import { useState } from 'react'
  3. import useSWR from 'swr'
  4. import { Card, CardContent } from '@/components/ui/card'
  5. import { Badge } from '@/components/ui/badge'
  6. import { Button } from '@/components/ui/button'
  7. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
  8. import { Switch } from '@/components/ui/switch'
  9. import { Label } from '@/components/ui/label'
  10. import {
  11. AlertDialog,
  12. AlertDialogAction,
  13. AlertDialogCancel,
  14. AlertDialogContent,
  15. AlertDialogDescription,
  16. AlertDialogFooter,
  17. AlertDialogHeader,
  18. AlertDialogTitle,
  19. } from '@/components/ui/alert-dialog'
  20. const fetcher = (url: string) => fetch(url).then((r) => r.json())
  21. interface PositionRow {
  22. id: number
  23. target_address: string
  24. target_nft_mint: string
  25. our_nft_mint: string | null
  26. pool_id: string
  27. pool_label: string
  28. tick_lower: number
  29. tick_upper: number
  30. price_lower: string
  31. price_upper: string
  32. size_usd: string
  33. amount_a: string
  34. amount_b: string
  35. symbol_a: string
  36. symbol_b: string
  37. pnl_usd: string | null
  38. pnl_percent: string | null
  39. apr: string | null
  40. bonus_usd: string | null
  41. earned_usd: string | null
  42. liquidity_usd: string | null
  43. status: string
  44. created_at: string
  45. }
  46. function formatPrice(price: string): string {
  47. if (!price) return ''
  48. const n = parseFloat(price)
  49. if (isNaN(n)) return price
  50. if (n >= 1000) return n.toFixed(2)
  51. if (n >= 1) return n.toFixed(4)
  52. if (n >= 0.0001) return n.toFixed(6)
  53. return n.toExponential(3)
  54. }
  55. function formatUsd(value: string | null): string {
  56. if (!value) return '-'
  57. const n = parseFloat(value)
  58. if (isNaN(n)) return value
  59. return n >= 0 ? `$${n.toFixed(2)}` : `-$${Math.abs(n).toFixed(2)}`
  60. }
  61. export default function PositionsPage() {
  62. const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
  63. const [closingId, setClosingId] = useState<number | null>(null)
  64. const [deletingId, setDeletingId] = useState<number | null>(null)
  65. const [error, setError] = useState<string | null>(null)
  66. const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
  67. const [showClosed, setShowClosed] = useState(false)
  68. const [syncing, setSyncing] = useState(false)
  69. const [syncResult, setSyncResult] = useState<string | null>(null)
  70. const allRows: PositionRow[] = positions || []
  71. const rows = showClosed ? allRows : allRows.filter((r) => r.status !== 'closed')
  72. const handleSync = async () => {
  73. setSyncing(true)
  74. setSyncResult(null)
  75. setError(null)
  76. try {
  77. const res = await fetch('/api/sync', { method: 'POST' })
  78. const data = await res.json()
  79. if (!res.ok) {
  80. setError(data.error || 'Sync failed')
  81. } else {
  82. setSyncResult(`Synced ${data.synced} positions, closed ${data.closed}, API total ${data.total}`)
  83. mutate()
  84. }
  85. } catch (e) {
  86. setError(e instanceof Error ? e.message : 'Sync failed')
  87. } finally {
  88. setSyncing(false)
  89. }
  90. }
  91. const handleClose = async (row: PositionRow) => {
  92. setClosingId(row.id)
  93. setError(null)
  94. try {
  95. const res = await fetch('/api/positions', {
  96. method: 'POST',
  97. headers: { 'Content-Type': 'application/json' },
  98. body: JSON.stringify({ id: row.id }),
  99. })
  100. const data = await res.json()
  101. if (!res.ok) {
  102. setError(data.error || 'Close failed')
  103. } else {
  104. mutate()
  105. }
  106. } catch (e) {
  107. setError(e instanceof Error ? e.message : 'Close failed')
  108. } finally {
  109. setClosingId(null)
  110. }
  111. }
  112. const handleDelete = async (row: PositionRow) => {
  113. setDeletingId(row.id)
  114. setError(null)
  115. try {
  116. const res = await fetch(`/api/positions?id=${row.id}`, { method: 'DELETE' })
  117. const data = await res.json()
  118. if (!res.ok) {
  119. setError(data.error || 'Delete failed')
  120. } else {
  121. mutate()
  122. }
  123. } catch (e) {
  124. setError(e instanceof Error ? e.message : 'Delete failed')
  125. } finally {
  126. setDeletingId(null)
  127. }
  128. }
  129. const handleConfirm = () => {
  130. if (!confirmAction) return
  131. if (confirmAction.type === 'close') {
  132. handleClose(confirmAction.row)
  133. } else {
  134. handleDelete(confirmAction.row)
  135. }
  136. setConfirmAction(null)
  137. }
  138. return (
  139. <div className="space-y-6">
  140. <div className="flex items-center justify-between">
  141. <h2 className="text-lg font-semibold">Position Mappings</h2>
  142. <div className="flex items-center gap-3">
  143. <Button onClick={handleSync} disabled={syncing} variant="outline" size="sm" className="text-xs">
  144. {syncing ? 'Syncing...' : 'Sync from Byreal'}
  145. </Button>
  146. <div className="flex items-center gap-2">
  147. <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
  148. <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
  149. Show closed
  150. </Label>
  151. </div>
  152. </div>
  153. </div>
  154. {error && (
  155. <div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-400">
  156. {error}
  157. <button onClick={() => setError(null)} className="ml-2 underline">
  158. dismiss
  159. </button>
  160. </div>
  161. )}
  162. {syncResult && (
  163. <div className="rounded-lg border border-green-500/30 bg-green-500/10 p-3 text-xs text-green-400">
  164. {syncResult}
  165. <button onClick={() => setSyncResult(null)} className="ml-2 underline">
  166. dismiss
  167. </button>
  168. </div>
  169. )}
  170. <Card>
  171. <CardContent className="p-0">
  172. {rows.length === 0 ? (
  173. <p className="text-xs text-muted-foreground p-4">No position mappings yet</p>
  174. ) : (
  175. <div className="overflow-x-auto">
  176. <Table>
  177. <TableHeader>
  178. <TableRow>
  179. <TableHead>Pool</TableHead>
  180. <TableHead>Size</TableHead>
  181. <TableHead>PNL</TableHead>
  182. <TableHead>Fee</TableHead>
  183. <TableHead>Bonus</TableHead>
  184. <TableHead>APR</TableHead>
  185. <TableHead>Price Range</TableHead>
  186. <TableHead>Target</TableHead>
  187. <TableHead>Our NFT</TableHead>
  188. <TableHead>Status</TableHead>
  189. <TableHead>Created</TableHead>
  190. <TableHead>Actions</TableHead>
  191. </TableRow>
  192. </TableHeader>
  193. <TableBody>
  194. {rows.map((row) => {
  195. const pnlNum = parseFloat(row.pnl_usd || '0')
  196. const pnlColor = pnlNum > 0 ? 'text-green-400' : pnlNum < 0 ? 'text-red-400' : 'text-muted-foreground'
  197. return (
  198. <TableRow key={row.id}>
  199. <TableCell className="text-muted-foreground">
  200. {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
  201. </TableCell>
  202. <TableCell>
  203. {(row.liquidity_usd || row.size_usd) ? (
  204. <div>
  205. <span className="text-foreground font-medium">
  206. {formatUsd(row.liquidity_usd || row.size_usd)}
  207. </span>
  208. {row.amount_a && row.symbol_a && (
  209. <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
  210. {row.amount_a} {row.symbol_a}
  211. {row.amount_b && ` + ${row.amount_b} ${row.symbol_b}`}
  212. </div>
  213. )}
  214. </div>
  215. ) : (
  216. <span className="text-muted-foreground">-</span>
  217. )}
  218. </TableCell>
  219. <TableCell>
  220. {row.pnl_usd ? (
  221. <div>
  222. <span className={`font-medium ${pnlColor}`}>{formatUsd(row.pnl_usd)}</span>
  223. {row.pnl_percent && (
  224. <div className={`text-[10px] ${pnlColor}`}>
  225. {parseFloat(row.pnl_percent) >= 0 ? '+' : ''}{parseFloat(row.pnl_percent).toFixed(2)}%
  226. </div>
  227. )}
  228. </div>
  229. ) : (
  230. <span className="text-muted-foreground">-</span>
  231. )}
  232. </TableCell>
  233. <TableCell>
  234. <span className={row.earned_usd && parseFloat(row.earned_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
  235. {formatUsd(row.earned_usd)}
  236. </span>
  237. </TableCell>
  238. <TableCell>
  239. <span className={row.bonus_usd && parseFloat(row.bonus_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
  240. {formatUsd(row.bonus_usd)}
  241. </span>
  242. </TableCell>
  243. <TableCell>
  244. {row.apr ? (
  245. <span className="text-foreground">{row.apr}%</span>
  246. ) : (
  247. <span className="text-muted-foreground">-</span>
  248. )}
  249. </TableCell>
  250. <TableCell className="text-muted-foreground text-[11px]">
  251. {row.price_lower && row.price_upper
  252. ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
  253. : `${row.tick_lower} ~ ${row.tick_upper}`}
  254. </TableCell>
  255. <TableCell>
  256. <a
  257. href={`https://solscan.io/account/${row.target_address}`}
  258. target="_blank"
  259. rel="noopener noreferrer"
  260. className="text-primary hover:underline text-[11px]"
  261. >
  262. {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
  263. </a>
  264. </TableCell>
  265. <TableCell>
  266. {row.our_nft_mint ? (
  267. <a
  268. href={`https://solscan.io/token/${row.our_nft_mint}`}
  269. target="_blank"
  270. rel="noopener noreferrer"
  271. className="text-primary hover:underline text-[11px]"
  272. >
  273. {row.our_nft_mint.slice(0, 6)}...
  274. </a>
  275. ) : (
  276. <span className="text-muted-foreground">pending</span>
  277. )}
  278. </TableCell>
  279. <TableCell>
  280. <Badge
  281. variant={
  282. row.status === 'active'
  283. ? 'success'
  284. : row.status === 'closed'
  285. ? 'secondary'
  286. : 'destructive'
  287. }
  288. >
  289. {row.status}
  290. </Badge>
  291. </TableCell>
  292. <TableCell className="text-muted-foreground">
  293. {new Date(row.created_at + 'Z').toLocaleString(undefined, {
  294. month: '2-digit',
  295. day: '2-digit',
  296. hour: '2-digit',
  297. minute: '2-digit',
  298. })}
  299. </TableCell>
  300. <TableCell>
  301. <div className="flex gap-1.5">
  302. {row.status === 'active' && row.our_nft_mint && (
  303. <Button
  304. variant="outline"
  305. size="sm"
  306. onClick={() => setConfirmAction({ type: 'close', row })}
  307. disabled={closingId === row.id}
  308. className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
  309. >
  310. {closingId === row.id ? 'Closing...' : 'Close'}
  311. </Button>
  312. )}
  313. <Button
  314. variant="destructive"
  315. size="sm"
  316. onClick={() => setConfirmAction({ type: 'delete', row })}
  317. disabled={deletingId === row.id}
  318. className="text-[10px] h-6 px-2"
  319. >
  320. {deletingId === row.id ? 'Deleting...' : 'Delete'}
  321. </Button>
  322. </div>
  323. </TableCell>
  324. </TableRow>
  325. )
  326. })}
  327. </TableBody>
  328. </Table>
  329. </div>
  330. )}
  331. </CardContent>
  332. </Card>
  333. <AlertDialog open={!!confirmAction} onOpenChange={(open) => !open && setConfirmAction(null)}>
  334. <AlertDialogContent>
  335. <AlertDialogHeader>
  336. <AlertDialogTitle>
  337. {confirmAction?.type === 'close' ? 'Close Position' : 'Delete Position'}
  338. </AlertDialogTitle>
  339. <AlertDialogDescription>
  340. {confirmAction?.type === 'close'
  341. ? `Close position ${confirmAction.row.our_nft_mint?.slice(0, 8)}...? This will remove all liquidity on-chain and swap tokens back to USDC.`
  342. : `Delete position mapping #${confirmAction?.row.id}? This only removes the record from the database, not from the blockchain.`}
  343. </AlertDialogDescription>
  344. </AlertDialogHeader>
  345. <AlertDialogFooter>
  346. <AlertDialogCancel>Cancel</AlertDialogCancel>
  347. <AlertDialogAction onClick={handleConfirm}>
  348. {confirmAction?.type === 'close' ? 'Close Position' : 'Delete'}
  349. </AlertDialogAction>
  350. </AlertDialogFooter>
  351. </AlertDialogContent>
  352. </AlertDialog>
  353. </div>
  354. )
  355. }