# 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`** ```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`** ```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('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
Loading listings…
} if (error) { return
{String(error)}
} const FILTERS: { key: Filter; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'active', label: 'Active' }, { key: 'inactive', label: 'Inactive' }, ] return (
{/* Header */}

Listings

{listings.length} ListingAccount PDAs · solisting program

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' }} />
{FILTERS.map((f) => ( ))}
{/* Table */}
LISTING
SELLER
PRICE
AVAIL / TOTAL
ORDERS
STATUS
{rows.length === 0 && (
No listings match these filters.
)} {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 (
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' }} >
{l.data.metadataUri || abbrev(l.address)}
#{String(l.data.listingId)} · {abbrev(l.address)}
{abbrev(l.data.seller)}
{priceLabel(l.data)}
{avail} / {String(l.data.quantity)}
{String(l.data.quantityReserved)}
{l.data.isActive ? 'Active' : 'Inactive'}
) })}
) } ``` 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`** ```tsx import { ListingsTable } from '@/components/ListingsTable' export default function ListingsPage() { return } ``` - [ ] **Step 4: Verify in browser** ```bash 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** ```bash 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`** ```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`** ```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. ```tsx '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
Loading…
if (error || !listing) return
Listing account not found.
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 = {} orders.forEach((o) => { const k = String(o.data.escrowId) escrowStateCounts[k] = (escrowStateCounts[k] ?? 0) + 1 }) return (
LISTING ACCOUNT · #{String(d.listingId)}

{d.metadataUri || abbrev(pk)}

{d.isActive ? 'Active' : 'Inactive'}
{/* Left: account fields */}
ACCOUNT FIELDS
#{String(d.listingId)} {}} onClick={() => router.push(`/search?q=${d.seller}`)} /> {priceStr()} {d.canonicalCurrency.__kind === 'Sol' ? 'SOL (native)' : `SPL · ${abbrev((d.canonicalCurrency as {mint: string}).mint)}`} {d.canonicalOracle && ( navigator.clipboard.writeText(d.canonicalOracle!)} /> )} {d.isActive ? 'Active' : 'Inactive'} {d.metadataUri && ( <>
METADATA URI
{d.metadataUri} )}
ALT CURRENCIES
{d.altCurrencies.length === 0 ?
None configured.
: d.altCurrencies.map((alt, i) => (
{alt.currency.__kind === 'Sol' ? 'SOL' : `SPL · ${abbrev((alt.currency as {mint:string}).mint)}`} {alt.usdOracle ? abbrev(alt.usdOracle) : 'stablecoin ($1.00)'}
)) }
ACCEPTED RESOLVERS
{d.acceptedResolvers.length === 0 ?
Empty — any registered resolver is accepted.
: d.acceptedResolvers.map((r) => (
router.push(`/resolver/${r}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 13px', background: 'var(--bg3)', borderRadius: 9, marginBottom: 7, cursor: 'pointer' }}> {abbrev(r)}
)) }
{/* Right: qty + actions */}
QUANTITY
{avail}
available
{String(d.quantityReserved)}
reserved
{String(d.quantity)}
total
ACTIONS
{isSeller && (
)} {canPlace && } {!walletAddress &&
Connect a wallet to place an order or manage this listing.
} {walletAddress && !isSeller && !canPlace &&
No actions available — listing is inactive or out of stock.
}
{/* Orders table */}
ORDERS ON THIS LISTING
ORDER
BUYER
AMOUNT
ORDER ID
ESCROW ID
{orders.length === 0 &&
No orders yet.
} {orders.map((o) => (
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' }} >
{abbrev(o.address)}
{abbrev(o.data.buyer)}
{fmtSol(o.data.amount)}
#{String(o.data.orderId)}
#{String(o.data.escrowId)}
))}
) } ``` - [ ] **Step 4: Create `app/src/app/listing/[pk]/page.tsx`** ```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 ( ) } ``` - [ ] **Step 5: Type-check + browser test** ```bash cd app && yarn typecheck ``` Navigate to `/listings`, click a row to go to `/listing/`. Expected: detail renders with fields, qty bar, orders table. - [ ] **Step 6: Commit** ```bash 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`** ```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[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[0]` if needed. - [ ] **Step 2: Create `app/src/components/EscrowStateMachine.tsx`** ```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> = { 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 (
ESCROW STATE MACHINE · descro
{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 (
{done ? '✓' : i + 1}
{step.label}
{i < STEPS.length - 1 && (
)}
) })}
{branch && (
{branch.label} {branch.desc}
)}
) } ``` - [ ] **Step 3: Create `app/src/components/OrderDetail.tsx`** ```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
Loading…
if (error || !data) return
Order account not found.
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 (
ORDER ACCOUNT · #{String(od.orderId)}

{abbrev(pk)}

{escrowStateKey}
{/* Order fields */}
ORDER ACCOUNT · solisting
#{String(od.orderId)} router.push(`/listing/${od.listingAccount}`)} onCopy={() => navigator.clipboard.writeText(od.listingAccount)} /> navigator.clipboard.writeText(od.buyer)} /> {fmtSol(od.amount)} #{String(od.escrowId)} navigator.clipboard.writeText(od.escrowAccount)} />
{/* Escrow fields */}
ESCROW ACCOUNT · descro
{!ed ?
Could not load escrow account.
: <> {escrowStateKey} navigator.clipboard.writeText(ed.seller)} /> navigator.clipboard.writeText(ed.buyer)} /> {fmtSol(ed.amount)} {ed.resolver && router.push(`/resolver/${ed.resolver}`)} onCopy={() => navigator.clipboard.writeText(ed.resolver!)} />} }
{/* Actions */}
AVAILABLE INSTRUCTIONS
{!walletAddress &&
Connect a wallet to act on this order.
} {walletAddress && (
{isSeller && escrowStateKey === 'AwaitingSellerConfirm' && onAccept && } {isSeller && escrowStateKey === 'AwaitingSellerConfirm' && onReject && } {isBuyer && escrowStateKey === 'AwaitingSellerConfirm' && onCancel && } {isBuyer && escrowStateKey === 'Active' && onComplete && } {(isSeller || isBuyer) && escrowStateKey === 'Active' && onDispute && } {(escrowStateKey === 'Complete' || escrowStateKey === 'Cancelled') && onCloseStale && }
)}
) } ``` 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`** ```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 } ``` - [ ] **Step 5: Type-check + browser test** ```bash 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** ```bash 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`** ```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[0]) }, enabled: !!cluster, refetchInterval: 30_000, }) } ``` - [ ] **Step 2: Create `app/src/components/EscrowsTable.tsx`** ```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 = { 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('all') const rows = escrows.filter((e) => filter === 'all' || e.data.state.__kind === filter) if (isLoading) return
Loading…
return (

Escrows

{escrows.length} EscrowAccount PDAs · descro program

{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 ( ) })}
ESCROW ID
SELLER
BUYER
AMOUNT
RESOLVER
STATE
{rows.length === 0 &&
No escrows in this state.
} {rows.map((e) => { const sc = stateColor(e.data.state.__kind) return (
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' }} >
#{String(e.data.escrowId)}
{abbrev(e.data.seller)}
{abbrev(e.data.buyer)}
{fmtSol(e.data.amount)}
{e.data.resolver ? abbrev(e.data.resolver) : '—'}
{e.data.state.__kind}
) })}
) } ``` 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`** ```tsx import { EscrowsTable } from '@/components/EscrowsTable' export default function EscrowsPage() { return } ``` - [ ] **Step 4: Type-check + commit** ```bash 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`** ```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[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`** ```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
Loading…
return (

Resolver Registry

{resolvers.length} ResolverEntry PDAs · descro_ext_resolvers program

{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 (
router.push(`/resolver/${r.address}`)} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '20px 22px', cursor: 'pointer' }} >
{d.name ?? abbrev(r.address)}
{abbrev(r.address)}
{feePct}% fee
{d.resolverType && {String(d.resolverType.__kind)}} {d.acceptancePolicy && {String(d.acceptancePolicy.__kind)}}
{total}
resolved
{buyerPct}%
for buyer
{sellerPct}%
for seller
) })} {resolvers.length === 0 &&
No resolvers registered.
}
) } ``` 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`** ```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
Loading…
if (!r) return
Resolver entry not found.
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 (
RESOLVER ENTRY · {abbrev(pk)}

{d.name ?? abbrev(pk)}

{d.description &&

{d.description}

}
ACCOUNT FIELDS
navigator.clipboard.writeText(d.authority)} /> {d.resolverType && {String(d.resolverType.__kind)}} {d.acceptancePolicy && {String(d.acceptancePolicy.__kind)}} {d.feeBps !== undefined && {(Number(d.feeBps) / 100).toFixed(2)}%} {d.feeRecipient && navigator.clipboard.writeText(d.feeRecipient!)} />} {d.metadataUri && {d.metadataUri}}
DISPUTE STATISTICS
{total}
total resolved
{[ { 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) => (
{stat.label} {stat.count} · {stat.pct}%
))}
) } ``` - [ ] **Step 4: Create pages** ```tsx // app/src/app/resolvers/page.tsx import { ResolverGrid } from '@/components/ResolverGrid' export default function ResolversPage() { return } ``` ```tsx // 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 } ``` ```tsx // 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 (
GLOBAL SEARCH

No account found

Nothing on-chain matched this query on the selected network.

{q}
) } export default function SearchPage() { return } ``` - [ ] **Step 5: Type-check + commit** ```bash 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`