feat: escrows view
This commit is contained in:
5
app/src/app/escrows/page.tsx
Normal file
5
app/src/app/escrows/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EscrowsTable } from '@/components/EscrowsTable'
|
||||
|
||||
export default function EscrowsPage() {
|
||||
return <EscrowsTable />
|
||||
}
|
||||
177
app/src/components/EscrowsTable.tsx
Normal file
177
app/src/components/EscrowsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
app/src/hooks/useEscrows.ts
Normal file
63
app/src/hooks/useEscrows.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user