53 KiB
Solisting App — Read Views (Part 3 of 4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement all read-only views: Listings table, Listing detail, Order detail with escrow state machine, Escrows table, Resolver grid, and Resolver detail. Each view has its own React Query hook that polls on-chain data via createSolanaRpc and the @solisting/sdk / @descro/sdk fetchers.
Architecture: Each page is 'use client'. Hooks use useQuery from @tanstack/react-query with createSolanaRpc(cluster.url) in the queryFn. Components use inline styles with CSS variables matching the prototype. abbrev, fmtSol, relTime from @/lib/format format display values.
Prerequisites: Parts 1 + 2 complete and committed. Dev server starts without errors.
File Map
| Path | Purpose |
|---|---|
app/src/hooks/useListings.ts |
React Query for all ListingAccounts |
app/src/hooks/useListing.ts |
Single listing + orders |
app/src/hooks/useOrdersForListing.ts |
Orders filtered by listing PDA |
app/src/hooks/useOrder.ts |
Single OrderAccount + linked EscrowAccount |
app/src/hooks/useEscrows.ts |
All descro EscrowAccounts |
app/src/hooks/useResolvers.ts |
All ResolverEntry accounts |
app/src/components/ListingsTable.tsx |
Table of all listings |
app/src/components/ListingDetail.tsx |
Full listing detail (read + action shell) |
app/src/components/EscrowStateMachine.tsx |
Visual state machine diagram |
app/src/components/OrderDetail.tsx |
OrderAccount + EscrowAccount side-by-side |
app/src/components/EscrowsTable.tsx |
Table of descro escrows |
app/src/components/ResolverGrid.tsx |
2-column resolver card grid |
app/src/components/ResolverDetail.tsx |
Resolver fields + dispute stats |
app/src/app/listings/page.tsx |
/listings route |
app/src/app/listing/[pk]/page.tsx |
/listing/[pk] route |
app/src/app/order/[pk]/page.tsx |
/order/[pk] route |
app/src/app/escrows/page.tsx |
/escrows route |
app/src/app/resolvers/page.tsx |
/resolvers route |
app/src/app/resolver/[pk]/page.tsx |
/resolver/[pk] route |
app/src/app/search/page.tsx |
/search?q= not-found fallback |
Task 14: Listings Hooks + View
Files: app/src/hooks/useListings.ts, app/src/components/ListingsTable.tsx, app/src/app/listings/page.tsx
- Step 1: Create
app/src/hooks/useListings.ts
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,
})
}
- Step 2: Create
app/src/components/ListingsTable.tsx
'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 as { decimals: number }).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 = Number(l.data.quantity) - Number(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)' }}> / {String(l.data.quantity)}</span>
</div>
<div style={{ fontSize: 13, color: 'var(--mut)' }}>{String(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>
)
}
Note: metadataUri is used as the display name for now. In a production app you'd fetch JSON from the URI. The name column shows the URI truncated or the PDA abbreviation.
- Step 3: Create
app/src/app/listings/page.tsx
import { ListingsTable } from '@/components/ListingsTable'
export default function ListingsPage() {
return <ListingsTable />
}
- Step 4: Verify in browser
cd app && yarn dev
Navigate to http://localhost:3000/listings. Expected: table loads with Solana devnet listings (or empty state if none exist). Loading spinner then data or empty message.
- Step 5: Commit
git add app/src/hooks/useListings.ts app/src/components/ListingsTable.tsx app/src/app/listings/
git commit -m "feat: listings view with useListings hook and table"
Task 15: Listing Detail
Files: app/src/hooks/useListing.ts, app/src/hooks/useOrdersForListing.ts, app/src/components/ListingDetail.tsx, app/src/app/listing/[pk]/page.tsx
- Step 1: Create
app/src/hooks/useListing.ts
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,
})
}
- Step 2: Create
app/src/hooks/useOrdersForListing.ts
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,
})
}
- Step 3: Create
app/src/components/ListingDetail.tsx
This component renders the listing detail page: fields panel, quantity bar, actions panel, and orders table. Actions (buttons) are rendered here but their onClick handlers are wired in Part 4.
'use client'
import { useRouter } from 'next/navigation'
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, relTime } 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 = Number(d.quantity) - Number(d.quantityReserved)
const availPct = d.quantity > 0n ? `${(avail / Number(d.quantity)) * 100}%` : '0%'
const reservedPct = d.quantity > 0n ? `${(Number(d.quantityReserved) / Number(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
function priceStr() {
const cur = d.canonicalCurrency
if (cur.__kind === 'Sol') return fmtSol(d.price)
return fmtTok(d.price, (cur as { decimals: number }).decimals, 'SPL')
}
const escrowStateCounts: Record<string, number> = {}
orders.forEach((o) => {
const k = String(o.data.escrowId)
escrowStateCounts[k] = (escrowStateCounts[k] ?? 0) + 1
})
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={d.seller} onCopy={() => {}} 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>
{d.canonicalOracle && (
<FieldRow label="Oracle">
<MonoChip value={abbrev(d.canonicalOracle)} onCopy={() => navigator.clipboard.writeText(d.canonicalOracle!)} />
</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)' }}>
{alt.usdOracle ? abbrev(alt.usdOracle) : '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' }}>{String(d.quantityReserved)}</div><div style={{ fontSize: 11, color: 'var(--mut)', marginTop: 2 }}>reserved</div></div>
<div><div style={{ fontSize: 24, fontWeight: 700 }}>{String(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>
)
}
- Step 4: Create
app/src/app/listing/[pk]/page.tsx
'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}
// onUpdate / onClose / onPlaceOrder wired in Part 4
/>
)
}
- Step 5: Type-check + browser test
cd app && yarn typecheck
Navigate to /listings, click a row to go to /listing/<pk>. Expected: detail renders with fields, qty bar, orders table.
- Step 6: Commit
git add app/src/hooks/useListing.ts app/src/hooks/useOrdersForListing.ts app/src/components/ListingDetail.tsx app/src/app/listing/
git commit -m "feat: listing detail view"
Task 16: Order Detail + Escrow State Machine
Files: app/src/hooks/useOrder.ts, app/src/components/EscrowStateMachine.tsx, app/src/components/OrderDetail.tsx, app/src/app/order/[pk]/page.tsx
- Step 1: Create
app/src/hooks/useOrder.ts
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrder, deriveEscrowId, findOrderPda } from '@solisting/sdk'
import { fetchEscrowAccount } from '@descro/sdk'
import type { Address } from '@solisting/sdk'
export function useOrder(pk: Address) {
const { cluster } = useCluster()
return useQuery({
queryKey: ['order', pk, cluster?.id],
queryFn: async () => {
const rpc = createSolanaRpc(cluster!.url)
const order = await fetchOrder(rpc, pk)
if (!order) return null
// Fetch the linked escrow from descro
const escrowPk = order.data.escrowAccount
const escrow = await fetchEscrowAccount(rpc as Parameters<typeof fetchEscrowAccount>[0], escrowPk).catch(() => null)
return { order, escrow }
},
enabled: !!cluster && !!pk,
refetchInterval: 15_000,
})
}
Note: fetchEscrowAccount is from @descro/sdk. Its RPC parameter type may differ slightly from @solana/kit's createSolanaRpc return — cast with as Parameters<typeof fetchEscrowAccount>[0] if needed.
- Step 2: Create
app/src/components/EscrowStateMachine.tsx
'use client'
// Renders the linear state machine: Created → AwaitingSellerConfirm → Active → [Complete|Cancelled|Disputed→Resolved]
// currentState comes from the descro EscrowAccount
const STEPS = [
{ key: 'AwaitingSellerConfirm', label: 'Awaiting Confirm' },
{ key: 'Active', label: 'Active' },
{ key: 'terminal', label: 'Done' },
]
type EscrowState = 'AwaitingSellerConfirm' | 'Active' | 'Disputed' | 'Complete' | 'Cancelled'
interface Props {
state: EscrowState | string
}
function stepIndex(state: string): number {
if (state === 'AwaitingSellerConfirm') return 0
if (state === 'Active' || state === 'Disputed') return 1
return 2
}
const STATE_LABELS: Partial<Record<string, { label: string; color: string; bg: string; desc: string }>> = {
Complete: { label: 'COMPLETE', color: '#4FD1E0', bg: 'rgba(79,209,224,.1)', desc: 'Buyer confirmed receipt. Seller has been paid.' },
Cancelled: { label: 'CANCELLED', color: '#8A8FA3', bg: 'rgba(138,143,163,.1)', desc: 'Escrow cancelled. Buyer has been refunded.' },
Disputed: { label: 'DISPUTED', color: '#FF7A59', bg: 'rgba(255,122,89,.1)', desc: 'Dispute raised. Awaiting resolver ruling.' },
}
export function EscrowStateMachine({ state }: Props) {
const currentIdx = stepIndex(state)
const branch = STATE_LABELS[state]
return (
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '24px 26px', marginBottom: 18 }}>
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, marginBottom: 22 }}>ESCROW STATE MACHINE · descro</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', maxWidth: 560, margin: '0 auto' }}>
{STEPS.map((step, i) => {
const done = i < currentIdx
const active = i === currentIdx
const fill = done ? 'var(--acc2)' : active ? 'var(--acc)' : 'var(--bg3)'
const ring = done ? 'var(--acc2)' : active ? 'var(--acc)' : 'var(--bd)'
const txt = active ? 'var(--tx)' : done ? 'var(--acc2light)' : 'var(--mut)'
return (
<div key={step.key} style={{ display: 'flex', alignItems: 'center', flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 9, flexShrink: 0 }}>
<div style={{ width: 34, height: 34, borderRadius: '50%', border: `2px solid ${ring}`, background: fill, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, color: done || active ? '#0b0613' : 'var(--mut)' }}>
{done ? '✓' : i + 1}
</div>
<span style={{ fontSize: 11.5, fontWeight: 600, color: txt, whiteSpace: 'nowrap' }}>{step.label}</span>
</div>
{i < STEPS.length - 1 && (
<div style={{ flex: 1, height: 2, background: done ? 'var(--acc2)' : 'var(--bd)', margin: '0 6px', marginBottom: 26 }} />
)}
</div>
)
})}
</div>
{branch && (
<div style={{ marginTop: 22, padding: '14px 16px', borderRadius: 11, background: branch.bg, display: 'flex', alignItems: 'center', gap: 13 }}>
<span style={{ display: 'inline-block', padding: '5px 13px', borderRadius: 999, fontSize: 12, fontWeight: 700, color: branch.color, border: `1px solid ${branch.color}` }}>{branch.label}</span>
<span style={{ fontSize: 13, color: 'var(--tx)' }}>{branch.desc}</span>
</div>
)}
</div>
)
}
- Step 3: Create
app/src/components/OrderDetail.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useOrder } from '@/hooks/useOrder'
import { EscrowStateMachine } from '@/components/EscrowStateMachine'
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { abbrev, fmtSol, relTime } from '@/lib/format'
import type { Address } from '@solisting/sdk'
interface Props {
pk: Address
walletAddress?: string | null
// Write action handlers — provided in Part 4
onAccept?: () => void
onReject?: () => void
onCancel?: () => void
onCloseStale?: () => void
onComplete?: () => void
onDispute?: () => void
}
export function OrderDetail({ pk, walletAddress, onAccept, onReject, onCancel, onCloseStale, onComplete, onDispute }: Props) {
const router = useRouter()
const { data, isLoading, error } = useOrder(pk)
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
if (error || !data) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Order account not found.</div>
const { order, escrow } = data
const od = order.data
const ed = escrow?.data
// Determine escrow state label for badge
const escrowStateKey = ed?.state?.__kind ?? 'Unknown'
const sc = STATUS_COLORS[
escrowStateKey === 'AwaitingSellerConfirm' ? 'awaitingConfirm' :
escrowStateKey === 'Active' ? 'active' :
escrowStateKey === 'Disputed' ? 'disputed' :
escrowStateKey === 'Complete' ? 'complete' : 'cancelled'
] ?? STATUS_COLORS.cancelled
const isSeller = walletAddress === od.seller
const isBuyer = walletAddress === od.buyer
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: 24, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 5 }}>ORDER ACCOUNT · #{String(od.orderId)}</div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>{abbrev(pk)}</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{escrowStateKey}</Badge>
</div>
<EscrowStateMachine state={escrowStateKey} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'start', marginBottom: 18 }}>
{/* Order 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' }}>ORDER ACCOUNT · solisting</div>
<FieldRow label="Order ID"><span style={{ fontSize: 13.5, fontWeight: 600 }}>#{String(od.orderId)}</span></FieldRow>
<FieldRow label="Listing"><MonoChip value={abbrev(od.listingAccount)} onClick={() => router.push(`/listing/${od.listingAccount}`)} onCopy={() => navigator.clipboard.writeText(od.listingAccount)} /></FieldRow>
<FieldRow label="Buyer"><MonoChip value={abbrev(od.buyer)} onCopy={() => navigator.clipboard.writeText(od.buyer)} /></FieldRow>
<FieldRow label="Amount"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtSol(od.amount)}</span></FieldRow>
<FieldRow label="Escrow ID"><span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>#{String(od.escrowId)}</span></FieldRow>
<FieldRow label="Escrow Account" last><MonoChip value={abbrev(od.escrowAccount)} onCopy={() => navigator.clipboard.writeText(od.escrowAccount)} /></FieldRow>
</div>
{/* Escrow 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' }}>ESCROW ACCOUNT · descro</div>
{!ed
? <div style={{ padding: '16px 0', color: 'var(--mut)', fontSize: 13 }}>Could not load escrow account.</div>
: <>
<FieldRow label="State"><Badge color={sc.color} bg={sc.bg}>{escrowStateKey}</Badge></FieldRow>
<FieldRow label="Seller"><MonoChip value={abbrev(ed.seller)} onCopy={() => navigator.clipboard.writeText(ed.seller)} /></FieldRow>
<FieldRow label="Buyer"><MonoChip value={abbrev(ed.buyer)} onCopy={() => navigator.clipboard.writeText(ed.buyer)} /></FieldRow>
<FieldRow label="Amount"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtSol(ed.amount)}</span></FieldRow>
{ed.resolver && <FieldRow label="Resolver"><MonoChip value={abbrev(ed.resolver)} onClick={() => router.push(`/resolver/${ed.resolver}`)} onCopy={() => navigator.clipboard.writeText(ed.resolver!)} /></FieldRow>}
</>
}
</div>
</div>
{/* Actions */}
<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 }}>AVAILABLE INSTRUCTIONS</div>
{!walletAddress && <div style={{ fontSize: 13, color: 'var(--mut)' }}>Connect a wallet to act on this order.</div>}
{walletAddress && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{isSeller && escrowStateKey === 'AwaitingSellerConfirm' && onAccept && <Button onClick={onAccept}>Accept Order</Button>}
{isSeller && escrowStateKey === 'AwaitingSellerConfirm' && onReject && <Button variant="danger" onClick={onReject}>Reject Order</Button>}
{isBuyer && escrowStateKey === 'AwaitingSellerConfirm' && onCancel && <Button variant="outline" onClick={onCancel}>Cancel Order</Button>}
{isBuyer && escrowStateKey === 'Active' && onComplete && <Button onClick={onComplete}>Complete</Button>}
{(isSeller || isBuyer) && escrowStateKey === 'Active' && onDispute && <Button variant="danger" onClick={onDispute}>Dispute</Button>}
{(escrowStateKey === 'Complete' || escrowStateKey === 'Cancelled') && onCloseStale && <Button variant="ghost" onClick={onCloseStale}>Close Stale Order</Button>}
</div>
)}
</div>
</div>
)
}
Note: od.seller — check the exact field name from the codama-generated OrderAccount type after Task 3. If the seller is stored on the ListingAccount (not OrderAccount), fetch it from the listing. The descro EscrowAccount has seller and buyer directly.
- Step 4: Create
app/src/app/order/[pk]/page.tsx
'use client'
import { use } from 'react'
import { useWallet } from '@solana/connector/react'
import { OrderDetail } from '@/components/OrderDetail'
import type { Address } from '@solisting/sdk'
export default function OrderPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const { account } = useWallet()
return <OrderDetail pk={pk as Address} walletAddress={account ?? null} />
}
- Step 5: Type-check + browser test
cd app && yarn typecheck
Navigate to an order PDA in the browser. Expected: both OrderAccount and EscrowAccount panels render, state machine shows the current step.
- Step 6: Commit
git add app/src/hooks/useOrder.ts app/src/components/EscrowStateMachine.tsx app/src/components/OrderDetail.tsx app/src/app/order/
git commit -m "feat: order detail with escrow state machine"
Task 17: Escrows View
Files: app/src/hooks/useEscrows.ts, app/src/components/EscrowsTable.tsx, app/src/app/escrows/page.tsx
- Step 1: Create
app/src/hooks/useEscrows.ts
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchAllEscrowAccounts } from '@descro/sdk'
export function useEscrows() {
const { cluster } = useCluster()
return useQuery({
queryKey: ['escrows', cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchAllEscrowAccounts(rpc as Parameters<typeof fetchAllEscrowAccounts>[0])
},
enabled: !!cluster,
refetchInterval: 30_000,
})
}
- Step 2: Create
app/src/components/EscrowsTable.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEscrows } from '@/hooks/useEscrows'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { abbrev, fmtSol } from '@/lib/format'
type EscrowFilter = 'all' | 'AwaitingSellerConfirm' | 'Active' | 'Disputed' | 'Complete' | 'Cancelled'
const FILTER_OPTS: { key: EscrowFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'AwaitingSellerConfirm', label: 'Awaiting' },
{ key: 'Active', label: 'Active' },
{ key: 'Disputed', label: 'Disputed' },
{ key: 'Complete', label: 'Complete' },
{ key: 'Cancelled', label: 'Cancelled' },
]
function stateColor(key: string) {
const map: Record<string, keyof typeof STATUS_COLORS> = {
AwaitingSellerConfirm: 'awaitingConfirm',
Active: 'active',
Disputed: 'disputed',
Complete: 'complete',
Cancelled: 'cancelled',
}
return STATUS_COLORS[map[key] ?? 'cancelled']
}
export function EscrowsTable() {
const router = useRouter()
const { data: escrows = [], isLoading } = useEscrows()
const [filter, setFilter] = useState<EscrowFilter>('all')
const rows = escrows.filter((e) => filter === 'all' || e.data.state.__kind === filter)
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
return (
<div>
<div style={{ marginBottom: 22 }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>Escrows</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, color: 'var(--mut)' }}>
{escrows.length} EscrowAccount PDAs · descro program
</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 18 }}>
{FILTER_OPTS.map((f) => {
const count = f.key === 'all' ? escrows.length : escrows.filter((e) => e.data.state.__kind === f.key).length
const active = filter === f.key
return (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '8px 14px', borderRadius: 9, fontSize: 12.5, fontWeight: 600, color: active ? 'var(--tx)' : 'var(--mut)', background: active ? 'var(--bg3)' : 'transparent', border: '1px solid var(--bd)' }}
>
{f.label}<span style={{ opacity: .7, fontSize: 11 }}>{count}</span>
</button>
)
})}
</div>
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1.1fr 1fr 1fr 1fr 1.2fr .9fr', gap: 12, padding: '13px 20px', borderBottom: '1px solid var(--bd)', fontSize: 10.5, fontWeight: 600, letterSpacing: '.12em', color: 'var(--mut)' }}>
<div>ESCROW ID</div><div>SELLER</div><div>BUYER</div><div>AMOUNT</div><div>RESOLVER</div><div style={{ textAlign: 'right' }}>STATE</div>
</div>
{rows.length === 0 && <div style={{ padding: 40, textAlign: 'center', color: 'var(--mut)', fontSize: 13 }}>No escrows in this state.</div>}
{rows.map((e) => {
const sc = stateColor(e.data.state.__kind)
return (
<div
key={e.address}
onClick={() => router.push(`/search?q=${e.address}`)}
style={{ display: 'grid', gridTemplateColumns: '1.1fr 1fr 1fr 1fr 1.2fr .9fr', 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)' }}>#{String(e.data.escrowId)}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>{abbrev(e.data.seller)}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>{abbrev(e.data.buyer)}</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{fmtSol(e.data.amount)}</div>
<div style={{ fontSize: 12.5, color: 'var(--mut)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.data.resolver ? abbrev(e.data.resolver) : '—'}</div>
<div style={{ textAlign: 'right' }}><Badge color={sc.color} bg={sc.bg}>{e.data.state.__kind}</Badge></div>
</div>
)
})}
</div>
</div>
)
}
Note: e.data.state.__kind assumes codama renders the Rust EscrowState enum with a __kind discriminant. Inspect @descro/sdk's generated escrowState.ts type and adjust if it uses a different shape.
- Step 3: Create
app/src/app/escrows/page.tsx
import { EscrowsTable } from '@/components/EscrowsTable'
export default function EscrowsPage() { return <EscrowsTable /> }
- Step 4: Type-check + commit
cd app && yarn typecheck
git add app/src/hooks/useEscrows.ts app/src/components/EscrowsTable.tsx app/src/app/escrows/
git commit -m "feat: escrows view"
Task 18: Resolvers Views
Files: app/src/hooks/useResolvers.ts, app/src/components/ResolverGrid.tsx, app/src/components/ResolverDetail.tsx, resolver pages
- Step 1: Create
app/src/hooks/useResolvers.ts
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchAllResolverEntryAccounts } from '@descro/sdk'
export function useResolvers() {
const { cluster } = useCluster()
return useQuery({
queryKey: ['resolvers', cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchAllResolverEntryAccounts(rpc as Parameters<typeof fetchAllResolverEntryAccounts>[0])
},
enabled: !!cluster,
refetchInterval: 30_000,
})
}
Adjust the function name to match @descro/sdk's actual export for ResolverEntry accounts (inspect @descro/sdk's index.ts).
- Step 2: Create
app/src/components/ResolverGrid.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useResolvers } from '@/hooks/useResolvers'
import { abbrev } from '@/lib/format'
export function ResolverGrid() {
const router = useRouter()
const { data: resolvers = [], isLoading } = useResolvers()
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
return (
<div>
<div style={{ marginBottom: 22 }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>Resolver Registry</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, color: 'var(--mut)' }}>
{resolvers.length} ResolverEntry PDAs · descro_ext_resolvers program
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{resolvers.map((r) => {
const d = r.data
const total = Number(d.ruledForBuyer ?? 0) + Number(d.ruledForSeller ?? 0)
const buyerPct = total > 0 ? Math.round(Number(d.ruledForBuyer ?? 0) / total * 100) : 0
const sellerPct = total > 0 ? 100 - buyerPct : 0
const feePct = (Number(d.feeBps ?? 0) / 100).toFixed(2)
return (
<div
key={r.address}
onClick={() => router.push(`/resolver/${r.address}`)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '20px 22px', cursor: 'pointer' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700 }}>{d.name ?? abbrev(r.address)}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--mut)', marginTop: 3 }}>{abbrev(r.address)}</div>
</div>
<span style={{ padding: '4px 11px', borderRadius: 999, fontSize: 11, fontWeight: 600, color: 'var(--acc2light)', background: 'var(--accSoft)', flexShrink: 0 }}>{feePct}% fee</span>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{d.resolverType && <span style={{ padding: '4px 10px', borderRadius: 7, fontSize: 11, fontWeight: 600, background: 'var(--bg3)', color: 'var(--mut)' }}>{String(d.resolverType.__kind)}</span>}
{d.acceptancePolicy && <span style={{ padding: '4px 10px', borderRadius: 7, fontSize: 11, fontWeight: 600, background: 'var(--bg3)', color: 'var(--mut)' }}>{String(d.acceptancePolicy.__kind)}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, textAlign: 'center', borderTop: '1px solid var(--bdSoft)', paddingTop: 14 }}>
<div><div style={{ fontSize: 18, fontWeight: 700 }}>{total}</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>resolved</div></div>
<div><div style={{ fontSize: 18, fontWeight: 700, color: 'var(--acc2light)' }}>{buyerPct}%</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>for buyer</div></div>
<div><div style={{ fontSize: 18, fontWeight: 700, color: '#F5B23E' }}>{sellerPct}%</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>for seller</div></div>
</div>
</div>
)
})}
{resolvers.length === 0 && <div style={{ gridColumn: '1/-1', padding: 48, textAlign: 'center', color: 'var(--mut)' }}>No resolvers registered.</div>}
</div>
</div>
)
}
Note: Field names (d.name, d.feeBps, d.ruledForBuyer, d.ruledForSeller, d.resolverType, d.acceptancePolicy) come from the @descro/sdk generated ResolverEntry type. Verify them against descro/sdk/src/generated/descro_ext_resolvers/accounts/resolverEntry.ts and adjust.
- Step 3: Create
app/src/components/ResolverDetail.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useResolvers } from '@/hooks/useResolvers'
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
import { abbrev } from '@/lib/format'
import type { Address } from '@solisting/sdk'
export function ResolverDetail({ pk }: { pk: Address }) {
const router = useRouter()
const { data: resolvers = [], isLoading } = useResolvers()
const r = resolvers.find((x) => x.address === pk)
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
if (!r) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Resolver entry not found.</div>
const d = r.data
const total = Number(d.ruledForBuyer ?? 0) + Number(d.ruledForSeller ?? 0)
const buyerPct = total > 0 ? Math.round(Number(d.ruledForBuyer ?? 0) / total * 100) : 0
const sellerPct = total > 0 ? 100 - buyerPct : 0
return (
<div>
<button onClick={() => router.push('/resolvers')} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}>
← Resolvers
</button>
<div style={{ marginBottom: 8, fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)' }}>RESOLVER ENTRY · {abbrev(pk)}</div>
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>{d.name ?? abbrev(pk)}</h1>
{d.description && <p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--mut)', maxWidth: 620, lineHeight: 1.55 }}>{d.description}</p>}
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 18, alignItems: 'start' }}>
<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="Authority"><MonoChip value={abbrev(d.authority)} onCopy={() => navigator.clipboard.writeText(d.authority)} /></FieldRow>
{d.resolverType && <FieldRow label="Type"><span style={{ fontSize: 13, fontWeight: 600 }}>{String(d.resolverType.__kind)}</span></FieldRow>}
{d.acceptancePolicy && <FieldRow label="Policy"><span style={{ fontSize: 13 }}>{String(d.acceptancePolicy.__kind)}</span></FieldRow>}
{d.feeBps !== undefined && <FieldRow label="Fee"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{(Number(d.feeBps) / 100).toFixed(2)}%</span></FieldRow>}
{d.feeRecipient && <FieldRow label="Fee Recipient"><MonoChip value={abbrev(d.feeRecipient)} onCopy={() => navigator.clipboard.writeText(d.feeRecipient!)} /></FieldRow>}
{d.metadataUri && <FieldRow label="Metadata"><a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ fontSize: 12.5, color: 'var(--acc2light)', wordBreak: 'break-all' }}>{d.metadataUri}</a></FieldRow>}
</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: 16 }}>DISPUTE STATISTICS</div>
<div style={{ textAlign: 'center', marginBottom: 18 }}>
<div style={{ fontSize: 34, fontWeight: 700 }}>{total}</div>
<div style={{ fontSize: 12, color: 'var(--mut)' }}>total resolved</div>
</div>
{[
{ label: 'Ruled for buyer', count: Number(d.ruledForBuyer ?? 0), pct: buyerPct, color: 'var(--acc2)', barColor: 'var(--acc2)' },
{ label: 'Ruled for seller', count: Number(d.ruledForSeller ?? 0), pct: sellerPct, color: '#F5B23E', barColor: '#F5B23E' },
].map((stat) => (
<div key={stat.label} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12.5, marginBottom: 6 }}>
<span style={{ color: stat.color, fontWeight: 600 }}>{stat.label}</span>
<span style={{ fontWeight: 600 }}>{stat.count} · {stat.pct}%</span>
</div>
<div style={{ height: 8, borderRadius: 5, background: 'var(--bg3)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${stat.pct}%`, background: stat.barColor }} />
</div>
</div>
))}
</div>
</div>
</div>
)
}
- Step 4: Create pages
// app/src/app/resolvers/page.tsx
import { ResolverGrid } from '@/components/ResolverGrid'
export default function ResolversPage() { return <ResolverGrid /> }
// app/src/app/resolver/[pk]/page.tsx
'use client'
import { use } from 'react'
import { ResolverDetail } from '@/components/ResolverDetail'
import type { Address } from '@solisting/sdk'
export default function ResolverPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
return <ResolverDetail pk={pk as Address} />
}
// app/src/app/search/page.tsx
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { Suspense } from 'react'
function SearchContent() {
const params = useSearchParams()
const router = useRouter()
const q = params.get('q') ?? ''
return (
<div style={{ maxWidth: 520, margin: '80px auto', textAlign: 'center' }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 12 }}>GLOBAL SEARCH</div>
<h1 style={{ margin: '0 0 10px', fontSize: 24, fontWeight: 700 }}>No account found</h1>
<p style={{ margin: '0 0 8px', fontSize: 14, color: 'var(--mut)', lineHeight: 1.55 }}>Nothing on-chain matched this query on the selected network.</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)', wordBreak: 'break-all', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 10, padding: '12px 16px', margin: '18px 0 24px' }}>{q}</div>
<button onClick={() => router.push('/listings')} style={{ padding: '11px 20px', borderRadius: 11, background: 'var(--bg3)', border: '1px solid var(--bd)', fontWeight: 600, fontSize: 13 }}>Back to Listings</button>
</div>
)
}
export default function SearchPage() {
return <Suspense><SearchContent /></Suspense>
}
- Step 5: Type-check + commit
cd app && yarn typecheck
git add app/src/hooks/useResolvers.ts app/src/components/ResolverGrid.tsx app/src/components/ResolverDetail.tsx app/src/app/resolvers/ app/src/app/resolver/ app/src/app/search/
git commit -m "feat: resolvers views and search fallback page"
Next: Proceed to Part 4 — 2026-06-22-solisting-app-writes-dashboard.md