feat: listing detail view
This commit is contained in:
18
app/src/app/listing/[pk]/page.tsx
Normal file
18
app/src/app/listing/[pk]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { useWallet } from '@solana/connector/react'
|
||||
import { ListingDetail } from '@/components/ListingDetail'
|
||||
import type { Address } from '@solisting/sdk'
|
||||
|
||||
export default function ListingPage({ params }: { params: Promise<{ pk: string }> }) {
|
||||
const { pk } = use(params)
|
||||
const { account } = useWallet()
|
||||
|
||||
return (
|
||||
<ListingDetail
|
||||
pk={pk as Address}
|
||||
walletAddress={account ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
170
app/src/components/ListingDetail.tsx
Normal file
170
app/src/components/ListingDetail.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isSome } from '@solana/kit'
|
||||
import { useListing } from '@/hooks/useListing'
|
||||
import { useOrdersForListing } from '@/hooks/useOrdersForListing'
|
||||
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
|
||||
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { abbrev, fmtSol, fmtTok } from '@/lib/format'
|
||||
import type { Address } from '@solisting/sdk'
|
||||
|
||||
interface Props {
|
||||
pk: Address
|
||||
walletAddress?: string | null
|
||||
onUpdate?: () => void
|
||||
onClose?: () => void
|
||||
onPlaceOrder?: () => void
|
||||
}
|
||||
|
||||
export function ListingDetail({ pk, walletAddress, onUpdate, onClose, onPlaceOrder }: Props) {
|
||||
const router = useRouter()
|
||||
const { data: listing, isLoading, error } = useListing(pk)
|
||||
const { data: orders = [] } = useOrdersForListing(pk)
|
||||
|
||||
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
|
||||
if (error || !listing) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Listing account not found.</div>
|
||||
|
||||
const d = listing.data
|
||||
const avail = d.quantity - d.quantityReserved
|
||||
const availPct = d.quantity > 0 ? `${(avail / d.quantity) * 100}%` : '0%'
|
||||
const reservedPct = d.quantity > 0 ? `${(d.quantityReserved / d.quantity) * 100}%` : '0%'
|
||||
const sc = STATUS_COLORS[d.isActive ? 'active' : 'inactive']
|
||||
|
||||
const isSeller = walletAddress && walletAddress === d.seller
|
||||
const canPlace = !isSeller && d.isActive && avail > 0 && walletAddress
|
||||
const canonicalOracleAddr = isSome(d.canonicalOracle) ? d.canonicalOracle.value : null
|
||||
|
||||
function priceStr() {
|
||||
const cur = d.canonicalCurrency
|
||||
if (cur.__kind === 'Sol') return fmtSol(d.price)
|
||||
return fmtTok(d.price, cur.decimals, 'SPL')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => router.push('/listings')} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}>
|
||||
← Listings
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 5 }}>
|
||||
LISTING ACCOUNT · #{String(d.listingId)}
|
||||
</div>
|
||||
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
|
||||
{d.metadataUri || abbrev(pk)}
|
||||
</h1>
|
||||
</div>
|
||||
<Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: 18, alignItems: 'start', marginBottom: 24 }}>
|
||||
{/* Left: account fields */}
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '8px 22px 16px' }}>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '14px 0 4px' }}>ACCOUNT FIELDS</div>
|
||||
<FieldRow label="Listing ID"><span style={{ fontSize: 13.5, fontWeight: 600 }}>#{String(d.listingId)}</span></FieldRow>
|
||||
<FieldRow label="Seller"><MonoChip value={abbrev(d.seller)} onCopy={() => navigator.clipboard.writeText(d.seller)} onClick={() => router.push(`/search?q=${d.seller}`)} /></FieldRow>
|
||||
<FieldRow label="Price"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{priceStr()}</span></FieldRow>
|
||||
<FieldRow label="Currency">
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>
|
||||
{d.canonicalCurrency.__kind === 'Sol' ? 'SOL (native)' : `SPL · ${abbrev((d.canonicalCurrency as { mint: string }).mint)}`}
|
||||
</span>
|
||||
</FieldRow>
|
||||
{canonicalOracleAddr && (
|
||||
<FieldRow label="Oracle">
|
||||
<MonoChip value={abbrev(canonicalOracleAddr)} onCopy={() => navigator.clipboard.writeText(canonicalOracleAddr)} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow label="Status"><Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge></FieldRow>
|
||||
|
||||
{d.metadataUri && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '18px 0 8px' }}>METADATA URI</div>
|
||||
<a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)', wordBreak: 'break-all' }}>
|
||||
{d.metadataUri}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '20px 0 10px' }}>ALT CURRENCIES</div>
|
||||
{d.altCurrencies.length === 0
|
||||
? <div style={{ fontSize: 13, color: 'var(--mut)' }}>None configured.</div>
|
||||
: d.altCurrencies.map((alt, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 12px', background: 'var(--bg3)', borderRadius: 9, marginBottom: 7 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{alt.currency.__kind === 'Sol' ? 'SOL' : `SPL · ${abbrev((alt.currency as { mint: string }).mint)}`}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--mut)' }}>
|
||||
{isSome(alt.usdOracle) ? abbrev(alt.usdOracle.value) : 'stablecoin ($1.00)'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '20px 0 10px' }}>ACCEPTED RESOLVERS</div>
|
||||
{d.acceptedResolvers.length === 0
|
||||
? <div style={{ fontSize: 13, color: 'var(--acc2light)' }}>Empty — any registered resolver is accepted.</div>
|
||||
: d.acceptedResolvers.map((r) => (
|
||||
<div key={r} onClick={() => router.push(`/resolver/${r}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 13px', background: 'var(--bg3)', borderRadius: 9, marginBottom: 7, cursor: 'pointer' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>{abbrev(r)}</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right: qty + actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, marginBottom: 14 }}>QUANTITY</div>
|
||||
<div style={{ display: 'flex', height: 12, borderRadius: 7, overflow: 'hidden', background: 'var(--bg3)', marginBottom: 14 }}>
|
||||
<div style={{ width: availPct, background: 'var(--acc2)' }} />
|
||||
<div style={{ width: reservedPct, background: '#F5B23E' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, textAlign: 'center' }}>
|
||||
<div><div style={{ fontSize: 24, fontWeight: 700, color: 'var(--acc2)' }}>{avail}</div><div style={{ fontSize: 11, color: 'var(--mut)', marginTop: 2 }}>available</div></div>
|
||||
<div><div style={{ fontSize: 24, fontWeight: 700, color: '#F5B23E' }}>{d.quantityReserved}</div><div style={{ fontSize: 11, color: 'var(--mut)', marginTop: 2 }}>reserved</div></div>
|
||||
<div><div style={{ fontSize: 24, fontWeight: 700 }}>{d.quantity}</div><div style={{ fontSize: 11, color: 'var(--mut)', marginTop: 2 }}>total</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, marginBottom: 14 }}>ACTIONS</div>
|
||||
{isSeller && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
<Button onClick={onUpdate}>Update Listing</Button>
|
||||
<Button variant="danger" onClick={onClose}>Close Listing</Button>
|
||||
</div>
|
||||
)}
|
||||
{canPlace && <Button onClick={onPlaceOrder}>Place Order</Button>}
|
||||
{!walletAddress && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>Connect a wallet to place an order or manage this listing.</div>}
|
||||
{walletAddress && !isSeller && !canPlace && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>No actions available — listing is inactive or out of stock.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders table */}
|
||||
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, margin: '0 0 12px' }}>ORDERS ON THIS LISTING</div>
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr', gap: 12, padding: '12px 20px', borderBottom: '1px solid var(--bd)', fontSize: 10.5, fontWeight: 600, letterSpacing: '.12em', color: 'var(--mut)' }}>
|
||||
<div>ORDER</div><div>BUYER</div><div>AMOUNT</div><div>ORDER ID</div><div style={{ textAlign: 'right' }}>ESCROW ID</div>
|
||||
</div>
|
||||
{orders.length === 0 && <div style={{ padding: 32, textAlign: 'center', color: 'var(--mut)', fontSize: 13 }}>No orders yet.</div>}
|
||||
{orders.map((o) => (
|
||||
<div
|
||||
key={o.address}
|
||||
onClick={() => router.push(`/order/${o.address}`)}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr', gap: 12, padding: '14px 20px', borderBottom: '1px solid var(--bdSoft)', cursor: 'pointer', alignItems: 'center' }}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)' }}>{abbrev(o.address)}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>{abbrev(o.data.buyer)}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{fmtSol(o.data.amount)}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>#{String(o.data.orderId)}</div>
|
||||
<div style={{ textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>#{String(o.data.escrowId)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
app/src/hooks/useListing.ts
Normal file
18
app/src/hooks/useListing.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCluster } from '@solana/connector/react'
|
||||
import { createSolanaRpc } from '@solana/kit'
|
||||
import { fetchListing } from '@solisting/sdk'
|
||||
import type { Address } from '@solisting/sdk'
|
||||
|
||||
export function useListing(pk: Address) {
|
||||
const { cluster } = useCluster()
|
||||
return useQuery({
|
||||
queryKey: ['listing', pk, cluster?.id],
|
||||
queryFn: () => {
|
||||
const rpc = createSolanaRpc(cluster!.url)
|
||||
return fetchListing(rpc, pk)
|
||||
},
|
||||
enabled: !!cluster && !!pk,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
}
|
||||
18
app/src/hooks/useOrdersForListing.ts
Normal file
18
app/src/hooks/useOrdersForListing.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCluster } from '@solana/connector/react'
|
||||
import { createSolanaRpc } from '@solana/kit'
|
||||
import { fetchOrdersForListing } from '@solisting/sdk'
|
||||
import type { Address } from '@solisting/sdk'
|
||||
|
||||
export function useOrdersForListing(listingPk: Address) {
|
||||
const { cluster } = useCluster()
|
||||
return useQuery({
|
||||
queryKey: ['orders', listingPk, cluster?.id],
|
||||
queryFn: () => {
|
||||
const rpc = createSolanaRpc(cluster!.url)
|
||||
return fetchOrdersForListing(rpc, listingPk)
|
||||
},
|
||||
enabled: !!cluster && !!listingPk,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user