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