feat: order detail with escrow state machine
This commit is contained in:
12
app/src/app/order/[pk]/page.tsx
Normal file
12
app/src/app/order/[pk]/page.tsx
Normal 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} />
|
||||
}
|
||||
67
app/src/components/EscrowStateMachine.tsx
Normal file
67
app/src/components/EscrowStateMachine.tsx
Normal 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 }
|
||||
126
app/src/components/OrderDetail.tsx
Normal file
126
app/src/components/OrderDetail.tsx
Normal 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
25
app/src/hooks/useOrder.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user