From 51be91e2aef3dbae0de45f167b9b8ed471b5759a Mon Sep 17 00:00:00 2001 From: thesn10 <38666407+thesn10@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:30:52 +0200 Subject: [PATCH] feat: order detail with escrow state machine --- app/src/app/order/[pk]/page.tsx | 12 +++ app/src/components/EscrowStateMachine.tsx | 67 ++++++++++++ app/src/components/OrderDetail.tsx | 126 ++++++++++++++++++++++ app/src/hooks/useOrder.ts | 25 +++++ 4 files changed, 230 insertions(+) create mode 100644 app/src/app/order/[pk]/page.tsx create mode 100644 app/src/components/EscrowStateMachine.tsx create mode 100644 app/src/components/OrderDetail.tsx create mode 100644 app/src/hooks/useOrder.ts diff --git a/app/src/app/order/[pk]/page.tsx b/app/src/app/order/[pk]/page.tsx new file mode 100644 index 0000000..e9d917b --- /dev/null +++ b/app/src/app/order/[pk]/page.tsx @@ -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 +} diff --git a/app/src/components/EscrowStateMachine.tsx b/app/src/components/EscrowStateMachine.tsx new file mode 100644 index 0000000..12f0ec6 --- /dev/null +++ b/app/src/components/EscrowStateMachine.tsx @@ -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.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 ( +
+
ESCROW STATE MACHINE · descro
+
+ {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 ( +
+
+
+ {done ? '✓' : i + 1} +
+ {step.label} +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ) + })} +
+ {branch && ( +
+ {branch.label} + {branch.desc} +
+ )} +
+ ) +} + +export { escrowStateLabel } diff --git a/app/src/components/OrderDetail.tsx b/app/src/components/OrderDetail.tsx new file mode 100644 index 0000000..9fb2a2f --- /dev/null +++ b/app/src/components/OrderDetail.tsx @@ -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
Loading…
+ if (error || !data) return
Order account not found.
+ + 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 ( +
+ + +
+
+
ORDER ACCOUNT · #{String(od.orderId)}
+

{abbrev(pk)}

+
+ {stateLabel} +
+ + {ed && } + +
+ {/* Order fields */} +
+
ORDER ACCOUNT · solisting
+ #{String(od.orderId)} + router.push(`/listing/${od.listing}`)} onCopy={() => navigator.clipboard.writeText(od.listing)} /> + navigator.clipboard.writeText(od.buyer)} /> + {fmtSol(od.amount)} + #{String(od.escrowId)} + navigator.clipboard.writeText(od.escrowAccount)} /> +
+ + {/* Escrow fields */} +
+
ESCROW ACCOUNT · descro
+ {!ed + ?
Could not load escrow account.
+ : <> + {stateLabel} + navigator.clipboard.writeText(ed.seller)} /> + navigator.clipboard.writeText(ed.buyer)} /> + {fmtSol(ed.amount)} + {isSome(ed.disputeResolver) && (() => { + const resolverAddr = ed.disputeResolver.value + return ( + + router.push(`/resolver/${resolverAddr}`)} + onCopy={() => navigator.clipboard.writeText(resolverAddr)} + /> + + ) + })()} + + } +
+
+ + {/* Actions */} +
+
AVAILABLE INSTRUCTIONS
+ {!walletAddress &&
Connect a wallet to act on this order.
} + {walletAddress && ( +
+ {isSeller && stateEnum === EscrowState.AwaitingSellerConfirm && onAccept && } + {isSeller && stateEnum === EscrowState.AwaitingSellerConfirm && onReject && } + {isBuyer && stateEnum === EscrowState.AwaitingSellerConfirm && onCancel && } + {isBuyer && stateEnum === EscrowState.Active && onComplete && } + {(isSeller || isBuyer) && stateEnum === EscrowState.Active && onDispute && } + {(stateEnum === EscrowState.Complete || stateEnum === EscrowState.Cancelled) && onCloseStale && } +
+ )} +
+
+ ) +} diff --git a/app/src/hooks/useOrder.ts b/app/src/hooks/useOrder.ts new file mode 100644 index 0000000..29678fc --- /dev/null +++ b/app/src/hooks/useOrder.ts @@ -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[0], + order.data.escrowAccount, + ).catch(() => null) + return { order, escrow } + }, + enabled: !!cluster && !!pk, + refetchInterval: 15_000, + }) +}