feat: listings view with useListings hook and table

This commit is contained in:
thesn10
2026-06-25 12:16:09 +02:00
parent a6b650b80a
commit 5201de35ce
3 changed files with 145 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { ListingsTable } from '@/components/ListingsTable'
export default function ListingsPage() {
return <ListingsTable />
}

View File

@@ -0,0 +1,123 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useListings } from '@/hooks/useListings'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { abbrev, fmtSol, fmtTok } from '@/lib/format'
import type { ListingAccountWithPda } from '@solisting/sdk'
type Filter = 'all' | 'active' | 'inactive'
function priceLabel(listing: ListingAccountWithPda['data']): string {
const cur = listing.canonicalCurrency
if (cur.__kind === 'Sol') return fmtSol(listing.price)
return fmtTok(listing.price, cur.decimals, 'SPL')
}
export function ListingsTable() {
const router = useRouter()
const { data: listings = [], isLoading, error } = useListings()
const [filter, setFilter] = useState<Filter>('all')
const [sellerFilter, setSellerFilter] = useState('')
const rows = listings.filter((l) => {
if (filter === 'active' && !l.data.isActive) return false
if (filter === 'inactive' && l.data.isActive) return false
if (sellerFilter && !l.data.seller.toLowerCase().includes(sellerFilter.toLowerCase())) return false
return true
})
if (isLoading) {
return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading listings</div>
}
if (error) {
return <div style={{ padding: 48, textAlign: 'center', color: 'var(--danger)' }}>{String(error)}</div>
}
const FILTERS: { key: Filter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'active', label: 'Active' },
{ key: 'inactive', label: 'Inactive' },
]
return (
<div>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, marginBottom: 22, flexWrap: 'wrap' }}>
<div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>Listings</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, color: 'var(--mut)' }}>
{listings.length} ListingAccount PDAs · solisting program
</p>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
value={sellerFilter}
onChange={(e) => setSellerFilter(e.target.value)}
placeholder="Filter by seller address"
style={{ height: 38, width: 220, padding: '0 13px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, fontSize: 13, fontFamily: 'var(--font-mono)', outline: 'none' }}
/>
<div style={{ display: 'flex', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 3 }}>
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{ padding: '6px 13px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, color: filter === f.key ? 'var(--tx)' : 'var(--mut)', background: filter === f.key ? 'var(--bg2)' : 'transparent' }}
>
{f.label}
</button>
))}
</div>
</div>
</div>
{/* Table */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '2.4fr 1.1fr 1fr 1.1fr .8fr .9fr', gap: 14, padding: '13px 20px', borderBottom: '1px solid var(--bd)', fontSize: 10.5, fontWeight: 600, letterSpacing: '.12em', color: 'var(--mut)' }}>
<div>LISTING</div><div>SELLER</div><div>PRICE</div><div>AVAIL / TOTAL</div><div>ORDERS</div><div style={{ textAlign: 'right' }}>STATUS</div>
</div>
{rows.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)', fontSize: 14 }}>
No listings match these filters.
</div>
)}
{rows.map((l) => {
const avail = l.data.quantity - l.data.quantityReserved
const statusKey = l.data.isActive ? 'active' : 'inactive'
const sc = STATUS_COLORS[statusKey]
return (
<div
key={l.address}
onClick={() => router.push(`/listing/${l.address}`)}
style={{ display: 'grid', gridTemplateColumns: '2.4fr 1.1fr 1fr 1.1fr .8fr .9fr', gap: 14, padding: '15px 20px', borderBottom: '1px solid var(--bdSoft)', cursor: 'pointer', alignItems: 'center' }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{l.data.metadataUri || abbrev(l.address)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--mut)', marginTop: 3 }}>
#{String(l.data.listingId)} · {abbrev(l.address)}
</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)' }}>
{abbrev(l.data.seller)}
</div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{priceLabel(l.data)}</div>
<div style={{ fontSize: 13 }}>
<span style={{ fontWeight: 600 }}>{avail}</span>
<span style={{ color: 'var(--mut)' }}> / {l.data.quantity}</span>
</div>
<div style={{ fontSize: 13, color: 'var(--mut)' }}>{l.data.quantityReserved}</div>
<div style={{ textAlign: 'right' }}>
<Badge color={sc.color} bg={sc.bg}>{l.data.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchAllListings } from '@solisting/sdk'
export function useListings() {
const { cluster } = useCluster()
return useQuery({
queryKey: ['listings', cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchAllListings(rpc)
},
enabled: !!cluster,
refetchInterval: 30_000,
})
}