Files
solisting/docs/superpowers/plans/2026-06-22-solisting-app-read-views.md

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