page.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. 'use client'
  2. import { useState } from 'react'
  3. import useSWR from 'swr'
  4. const fetcher = (url: string) => fetch(url).then((r) => r.json())
  5. interface PositionRow {
  6. id: number
  7. target_address: string
  8. target_nft_mint: string
  9. our_nft_mint: string | null
  10. pool_id: string
  11. pool_label: string
  12. tick_lower: number
  13. tick_upper: number
  14. price_lower: string
  15. price_upper: string
  16. size_usd: string
  17. amount_a: string
  18. amount_b: string
  19. symbol_a: string
  20. symbol_b: string
  21. status: string
  22. created_at: string
  23. }
  24. function formatPrice(price: string): string {
  25. if (!price) return ''
  26. const n = parseFloat(price)
  27. if (isNaN(n)) return price
  28. if (n >= 1000) return n.toFixed(2)
  29. if (n >= 1) return n.toFixed(4)
  30. if (n >= 0.0001) return n.toFixed(6)
  31. return n.toExponential(3)
  32. }
  33. export default function PositionsPage() {
  34. const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
  35. const [closingId, setClosingId] = useState<number | null>(null)
  36. const [deletingId, setDeletingId] = useState<number | null>(null)
  37. const [error, setError] = useState<string | null>(null)
  38. const rows: PositionRow[] = positions || []
  39. const handleClose = async (row: PositionRow) => {
  40. if (
  41. !confirm(
  42. `Close position ${row.our_nft_mint?.slice(0, 8)}...? This will remove all liquidity on-chain and swap tokens back to USDC.`,
  43. )
  44. ) {
  45. return
  46. }
  47. setClosingId(row.id)
  48. setError(null)
  49. try {
  50. const res = await fetch('/api/positions', {
  51. method: 'POST',
  52. headers: { 'Content-Type': 'application/json' },
  53. body: JSON.stringify({ id: row.id }),
  54. })
  55. const data = await res.json()
  56. if (!res.ok) {
  57. setError(data.error || 'Close failed')
  58. } else {
  59. mutate()
  60. }
  61. } catch (e) {
  62. setError(e instanceof Error ? e.message : 'Close failed')
  63. } finally {
  64. setClosingId(null)
  65. }
  66. }
  67. const handleDelete = async (row: PositionRow) => {
  68. if (
  69. !confirm(
  70. `Delete position mapping #${row.id}? This only removes the record from the database, not from the blockchain.`,
  71. )
  72. ) {
  73. return
  74. }
  75. setDeletingId(row.id)
  76. setError(null)
  77. try {
  78. const res = await fetch(`/api/positions?id=${row.id}`, { method: 'DELETE' })
  79. const data = await res.json()
  80. if (!res.ok) {
  81. setError(data.error || 'Delete failed')
  82. } else {
  83. mutate()
  84. }
  85. } catch (e) {
  86. setError(e instanceof Error ? e.message : 'Delete failed')
  87. } finally {
  88. setDeletingId(null)
  89. }
  90. }
  91. return (
  92. <div className="space-y-6">
  93. <h2 className="text-lg font-semibold">Position Mappings</h2>
  94. {error && (
  95. <div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-400">
  96. {error}
  97. <button onClick={() => setError(null)} className="ml-2 underline">
  98. dismiss
  99. </button>
  100. </div>
  101. )}
  102. <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
  103. {rows.length === 0 ? (
  104. <p className="text-xs text-zinc-500">No position mappings yet</p>
  105. ) : (
  106. <div className="overflow-x-auto">
  107. <table className="w-full text-xs">
  108. <thead>
  109. <tr className="text-zinc-500 border-b border-[var(--border)]">
  110. <th className="text-left py-2 pr-3">Target</th>
  111. <th className="text-left py-2 pr-3">Target NFT</th>
  112. <th className="text-left py-2 pr-3">Our NFT</th>
  113. <th className="text-left py-2 pr-3">Pool</th>
  114. <th className="text-left py-2 pr-3">Size</th>
  115. <th className="text-left py-2 pr-3">Price Range</th>
  116. <th className="text-left py-2 pr-3">Status</th>
  117. <th className="text-left py-2 pr-3">Created</th>
  118. <th className="text-left py-2">Actions</th>
  119. </tr>
  120. </thead>
  121. <tbody>
  122. {rows.map((row) => (
  123. <tr key={row.id} className="border-b border-zinc-800/50">
  124. <td className="py-2 pr-3 text-zinc-400">
  125. {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
  126. </td>
  127. <td className="py-2 pr-3">
  128. <a
  129. href={`https://solscan.io/token/${row.target_nft_mint}`}
  130. target="_blank"
  131. rel="noopener noreferrer"
  132. className="text-indigo-400 hover:underline"
  133. >
  134. {row.target_nft_mint.slice(0, 6)}...
  135. </a>
  136. </td>
  137. <td className="py-2 pr-3">
  138. {row.our_nft_mint ? (
  139. <a
  140. href={`https://solscan.io/token/${row.our_nft_mint}`}
  141. target="_blank"
  142. rel="noopener noreferrer"
  143. className="text-indigo-400 hover:underline"
  144. >
  145. {row.our_nft_mint.slice(0, 6)}...
  146. </a>
  147. ) : (
  148. <span className="text-zinc-500">pending</span>
  149. )}
  150. </td>
  151. <td className="py-2 pr-3 text-zinc-400">
  152. {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
  153. </td>
  154. <td className="py-2 pr-3">
  155. {row.size_usd ? (
  156. <div>
  157. <span className="text-green-400 font-medium">${row.size_usd}</span>
  158. <div className="text-zinc-500 text-[10px] leading-tight mt-0.5">
  159. {row.amount_a && row.symbol_a && `${row.amount_a} ${row.symbol_a}`}
  160. {row.amount_a && row.amount_b && ' + '}
  161. {row.amount_b && row.symbol_b && `${row.amount_b} ${row.symbol_b}`}
  162. </div>
  163. </div>
  164. ) : (
  165. <span className="text-zinc-500">-</span>
  166. )}
  167. </td>
  168. <td className="py-2 pr-3 text-zinc-400">
  169. {row.price_lower && row.price_upper
  170. ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
  171. : `${row.tick_lower} ~ ${row.tick_upper}`}
  172. </td>
  173. <td className="py-2 pr-3">
  174. <span
  175. className={`px-1.5 py-0.5 rounded text-[10px] ${
  176. row.status === 'active'
  177. ? 'bg-green-500/20 text-green-400'
  178. : row.status === 'closed'
  179. ? 'bg-zinc-500/20 text-zinc-400'
  180. : 'bg-red-500/20 text-red-400'
  181. }`}
  182. >
  183. {row.status}
  184. </span>
  185. </td>
  186. <td className="py-2 pr-3 text-zinc-500">
  187. {new Date(row.created_at + 'Z').toLocaleDateString()}
  188. </td>
  189. <td className="py-2">
  190. <div className="flex gap-1.5">
  191. {row.status === 'active' && row.our_nft_mint && (
  192. <button
  193. onClick={() => handleClose(row)}
  194. disabled={closingId === row.id}
  195. className="px-2 py-0.5 text-[10px] rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 disabled:opacity-50 transition-colors"
  196. >
  197. {closingId === row.id ? 'Closing...' : 'Close'}
  198. </button>
  199. )}
  200. <button
  201. onClick={() => handleDelete(row)}
  202. disabled={deletingId === row.id}
  203. className="px-2 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 transition-colors"
  204. >
  205. {deletingId === row.id ? 'Deleting...' : 'Delete'}
  206. </button>
  207. </div>
  208. </td>
  209. </tr>
  210. ))}
  211. </tbody>
  212. </table>
  213. </div>
  214. )}
  215. </div>
  216. </div>
  217. )
  218. }