1107 lines
53 KiB
Markdown
1107 lines
53 KiB
Markdown
# 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<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`**
|
|
|
|
```tsx
|
|
import { ListingsTable } from '@/components/ListingsTable'
|
|
|
|
export default function ListingsPage() {
|
|
return <ListingsTable />
|
|
}
|
|
```
|
|
|
|
- [ ] **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 <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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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<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`**
|
|
|
|
```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`**
|
|
|
|
```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`**
|
|
|
|
```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**
|
|
|
|
```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<typeof fetchAllEscrowAccounts>[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<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`**
|
|
|
|
```tsx
|
|
import { EscrowsTable } from '@/components/EscrowsTable'
|
|
export default function EscrowsPage() { return <EscrowsTable /> }
|
|
```
|
|
|
|
- [ ] **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<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`**
|
|
|
|
```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`**
|
|
|
|
```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**
|
|
|
|
```tsx
|
|
// app/src/app/resolvers/page.tsx
|
|
import { ResolverGrid } from '@/components/ResolverGrid'
|
|
export default function ResolversPage() { return <ResolverGrid /> }
|
|
```
|
|
|
|
```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 <ResolverDetail pk={pk as Address} />
|
|
}
|
|
```
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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`
|