feat: escrows view

This commit is contained in:
thesn10
2026-06-25 12:34:39 +02:00
parent 51be91e2ae
commit ef7dca34f9
3 changed files with 245 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { EscrowsTable } from '@/components/EscrowsTable'
export default function EscrowsPage() {
return <EscrowsTable />
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { isSome } from '@solana/kit'
import { useEscrows } from '@/hooks/useEscrows'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { abbrev, fmtSol } from '@/lib/format'
import { escrowStateLabel } from '@descro/sdk'
import { EscrowState } from '@descro/sdk/src/generated/descro/src/generated/types/escrowState'
type FilterKey = 'all' | 'awaiting' | 'active' | 'disputed' | 'complete' | 'cancelled'
const FILTERS: { key: FilterKey; label: string; state?: EscrowState }[] = [
{ key: 'all', label: 'All' },
{ key: 'awaiting', label: 'Awaiting', state: EscrowState.AwaitingSellerConfirm },
{ key: 'active', label: 'Active', state: EscrowState.Active },
{ key: 'disputed', label: 'Disputed', state: EscrowState.Disputed },
{ key: 'complete', label: 'Complete', state: EscrowState.Complete },
{ key: 'cancelled', label: 'Cancelled', state: EscrowState.Cancelled },
]
function stateToStatusKey(state: EscrowState): keyof typeof STATUS_COLORS {
switch (state) {
case EscrowState.AwaitingSellerConfirm:
return 'awaitingConfirm'
case EscrowState.Active:
return 'active'
case EscrowState.Disputed:
return 'disputed'
case EscrowState.Complete:
return 'complete'
case EscrowState.AwaitingDeposit:
case EscrowState.Cancelled:
default:
return 'cancelled'
}
}
export function EscrowsTable() {
const router = useRouter()
const { data: escrows = [], isLoading, error } = useEscrows()
const [filter, setFilter] = useState<FilterKey>('all')
if (isLoading) {
return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading escrows</div>
}
if (error) {
return <div style={{ padding: 48, textAlign: 'center', color: 'var(--danger)' }}>{String(error)}</div>
}
const rows = escrows.filter((e) => {
if (filter === 'all') return true
const f = FILTERS.find((f) => f.key === filter)
if (!f || f.state === undefined) return true
return e.account.state === f.state
})
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' }}>Escrows</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, color: 'var(--mut)' }}>
{escrows.length} EscrowAccount PDAs · descro program
</p>
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 3 }}>
{FILTERS.map((f) => {
const count = f.key === 'all'
? escrows.length
: escrows.filter((e) => e.account.state === f.state).length
return (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{
padding: '6px 12px',
borderRadius: 7,
fontSize: 12.5,
fontWeight: 600,
color: filter === f.key ? 'var(--tx)' : 'var(--mut)',
background: filter === f.key ? 'var(--bg2)' : 'transparent',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{f.label}
{count > 0 && (
<span style={{ marginLeft: 5, fontSize: 11, opacity: 0.7 }}>({count})</span>
)}
</button>
)
})}
</div>
</div>
{/* Table */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',
gap: 14,
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: 48, textAlign: 'center', color: 'var(--mut)', fontSize: 14 }}>
No escrows match these filters.
</div>
)}
{rows.map((e) => {
const statusKey = stateToStatusKey(e.account.state)
const sc = STATUS_COLORS[statusKey]
const resolverDisplay = isSome(e.account.disputeResolver)
? abbrev(e.account.disputeResolver.value)
: '—'
return (
<div
key={e.pda}
onClick={() => router.push(`/search?q=${e.pda}`)}
style={{
display: 'grid',
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',
gap: 14,
padding: '15px 20px',
borderBottom: '1px solid var(--bdSoft)',
cursor: 'pointer',
alignItems: 'center',
}}
>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--mut)' }}>
#{String(e.account.escrowId)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)' }}>
{abbrev(e.account.seller)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)' }}>
{abbrev(e.account.buyer)}
</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{fmtSol(e.account.amount)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--mut)' }}>
{resolverDisplay}
</div>
<div style={{ textAlign: 'right' }}>
<Badge color={sc.color} bg={sc.bg}>
{escrowStateLabel(e.account.state)}
</Badge>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import type { Address, Lamports } from '@solana/kit'
import {
decodeEscrowAccount,
getEscrowAccountDiscriminatorBytes,
} from '@descro/sdk/src/generated/descro/src/generated/accounts/escrowAccount'
import { DESCRO_PROGRAM_ADDRESS } from '@descro/sdk/src/generated/descro/src/generated/programs/descro'
import type { EscrowAccountWithPda } from '@descro/sdk'
async function fetchAllEscrows(
rpc: ReturnType<typeof createSolanaRpc>,
): Promise<EscrowAccountWithPda[]> {
const discriminatorBase64 = btoa(
String.fromCharCode(...getEscrowAccountDiscriminatorBytes()),
) as never
const results = await rpc
.getProgramAccounts(DESCRO_PROGRAM_ADDRESS, {
encoding: 'base64',
filters: [{ memcmp: { offset: 0n, bytes: discriminatorBase64, encoding: 'base64' } }],
})
.send()
return (
results as Array<{
pubkey: Address
account: {
executable: boolean
lamports: bigint
owner: Address
space: bigint
data: [string, 'base64']
}
}>
).flatMap((r) => {
const rawBytes = Uint8Array.from(atob(r.account.data[0]), (c) => c.charCodeAt(0))
const decoded = decodeEscrowAccount({
address: r.pubkey,
data: rawBytes,
executable: r.account.executable,
lamports: r.account.lamports as Lamports,
programAddress: r.account.owner,
space: r.account.space,
exists: true,
})
return decoded.exists ? [{ pda: r.pubkey, account: decoded.data }] : []
})
}
export function useEscrows() {
const { cluster } = useCluster()
return useQuery({
queryKey: ['escrows', cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchAllEscrows(rpc)
},
enabled: !!cluster,
refetchInterval: 30_000,
})
}