feat: order detail with escrow state machine

This commit is contained in:
thesn10
2026-06-25 12:30:52 +02:00
parent 5cc87c1569
commit 51be91e2ae
4 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
'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} />
}

View File

@@ -0,0 +1,67 @@
'use client'
import { escrowStateLabel } from '@descro/sdk'
import { EscrowState } from '@descro/sdk/src/generated/descro/src/generated/types/escrowState'
const STEPS = [
{ key: EscrowState.AwaitingSellerConfirm, label: 'Awaiting Confirm' },
{ key: EscrowState.Active, label: 'Active' },
{ key: 'terminal', label: 'Done' },
]
interface Props {
state: EscrowState
}
function stepIndex(state: EscrowState): number {
if (state === EscrowState.AwaitingSellerConfirm) return 0
if (state === EscrowState.Active || state === EscrowState.Disputed) return 1
return 2
}
const TERMINAL_INFO = new Map<EscrowState, { label: string; color: string; bg: string; desc: string }>([
[EscrowState.Complete, { label: 'COMPLETE', color: '#4FD1E0', bg: 'rgba(79,209,224,.1)', desc: 'Buyer confirmed receipt. Seller has been paid.' }],
[EscrowState.Cancelled, { label: 'CANCELLED', color: '#8A8FA3', bg: 'rgba(138,143,163,.1)', desc: 'Escrow cancelled. Buyer has been refunded.' }],
[EscrowState.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 = TERMINAL_INFO.get(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={i} 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>
)
}
export { escrowStateLabel }

View File

@@ -0,0 +1,126 @@
'use client'
import { useRouter } from 'next/navigation'
import { isSome } from '@solana/kit'
import { escrowStateLabel } from '@descro/sdk'
import { EscrowState } from '@descro/sdk/src/generated/descro/src/generated/types/escrowState'
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 } from '@/lib/format'
import type { Address } from '@solisting/sdk'
interface Props {
pk: Address
walletAddress?: string | null
onAccept?: () => void
onReject?: () => void
onCancel?: () => void
onCloseStale?: () => void
onComplete?: () => void
onDispute?: () => void
}
function escrowStatusKey(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'
default: return 'cancelled'
}
}
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
const stateEnum = ed?.state ?? EscrowState.Cancelled
const stateLabel = escrowStateLabel(stateEnum)
const sc = STATUS_COLORS[escrowStatusKey(stateEnum)]
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}>{stateLabel}</Badge>
</div>
{ed && <EscrowStateMachine state={ed.state} />}
<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.listing)} onClick={() => router.push(`/listing/${od.listing}`)} onCopy={() => navigator.clipboard.writeText(od.listing)} /></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}>{stateLabel}</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>
{isSome(ed.disputeResolver) && (() => {
const resolverAddr = ed.disputeResolver.value
return (
<FieldRow label="Resolver">
<MonoChip
value={abbrev(resolverAddr)}
onClick={() => router.push(`/resolver/${resolverAddr}`)}
onCopy={() => navigator.clipboard.writeText(resolverAddr)}
/>
</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 && stateEnum === EscrowState.AwaitingSellerConfirm && onAccept && <Button onClick={onAccept}>Accept Order</Button>}
{isSeller && stateEnum === EscrowState.AwaitingSellerConfirm && onReject && <Button variant="danger" onClick={onReject}>Reject Order</Button>}
{isBuyer && stateEnum === EscrowState.AwaitingSellerConfirm && onCancel && <Button variant="outline" onClick={onCancel}>Cancel Order</Button>}
{isBuyer && stateEnum === EscrowState.Active && onComplete && <Button onClick={onComplete}>Complete</Button>}
{(isSeller || isBuyer) && stateEnum === EscrowState.Active && onDispute && <Button variant="danger" onClick={onDispute}>Dispute</Button>}
{(stateEnum === EscrowState.Complete || stateEnum === EscrowState.Cancelled) && onCloseStale && <Button variant="ghost" onClick={onCloseStale}>Close Stale Order</Button>}
</div>
)}
</div>
</div>
)
}

25
app/src/hooks/useOrder.ts Normal file
View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrder } 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
const escrow = await fetchEscrowAccount(
rpc as Parameters<typeof fetchEscrowAccount>[0],
order.data.escrowAccount,
).catch(() => null)
return { order, escrow }
},
enabled: !!cluster && !!pk,
refetchInterval: 15_000,
})
}