Files
solisting/app/src/components/ListingDetail.tsx

177 lines
11 KiB
TypeScript

'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, priceStr } 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 fmtListingPrice() {
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' }}>
{abbrev(pk)}
</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
{d.metadataUri && (
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--mut)', maxWidth: 620, lineHeight: 1.55 }}>
<a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ color: 'var(--acc2light)' }}>{d.metadataUri}</a>
</p>
)}
<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="Address"><MonoChip value={abbrev(pk)} onCopy={() => navigator.clipboard.writeText(pk)} /></FieldRow>
<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 }}>{fmtListingPrice()}</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 style={{ width: '100%', padding: 11 }} 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 }}>{priceStr(o.data.paymentCurrency, 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>
)
}