32 KiB
Solisting App — Write Transactions + Dashboard (Part 4 of 4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement all write transactions (7 solisting instructions + 3 descro-side actions), the useTx helper hook, the Create/Update Listing form, the Dashboard, and wire action handlers into ListingDetail and OrderDetail.
Architecture: useTx encapsulates the @solana/kit transaction pipeline (build → sign → confirm) using useKitTransactionSigner + useSolanaClient from @solana/connector/react. After confirmation it calls useToast and queryClient.invalidateQueries. All instruction builders come from @solisting/sdk (codama-generated). Dashboard pages filter React Query cache by the connected wallet address.
Prerequisites: Parts 1–3 complete. The codama-generated instruction builders are available from @solisting/sdk. SOLISTING_PROGRAM_ADDRESS is exported from the SDK.
File Map
| Path | Purpose |
|---|---|
app/src/hooks/useTx.ts |
Generic send-tx + toast + cache invalidation |
app/src/hooks/useMyListings.ts |
User's listings (filters useListings cache) |
app/src/hooks/useMyOrders.ts |
User's orders as buyer |
app/src/components/CreateListingForm.tsx |
Create + update listing form |
app/src/components/Dashboard.tsx |
My Listings + My Orders tabs |
app/src/app/listing/create/page.tsx |
/listing/create?edit=<pk> |
app/src/app/dashboard/page.tsx |
/dashboard |
Modified files (adding write handlers):
| Path | Change |
|---|---|
app/src/app/listing/[pk]/page.tsx |
Wire onUpdate, onClose, onPlaceOrder |
app/src/app/order/[pk]/page.tsx |
Wire all order action handlers |
Task 19: useTx Hook
Files: app/src/hooks/useTx.ts
- Step 1: Create
app/src/hooks/useTx.ts
import {
pipe,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signTransactionMessageWithSigners,
sendAndConfirmTransactionFactory,
assertIsTransactionWithBlockhashLifetime,
} from '@solana/kit'
import { useKitTransactionSigner, useSolanaClient } from '@solana/connector/react'
import { useQueryClient } from '@tanstack/react-query'
import { useToast } from '@/components/ui/Toast'
type AnyInstruction = Parameters<typeof appendTransactionMessageInstructions>[0][number]
export function useTx() {
const { signer } = useKitTransactionSigner()
const { client } = useSolanaClient()
const queryClient = useQueryClient()
const toast = useToast()
return async (
instructions: AnyInstruction[],
invalidateKeys: string[][],
label: string,
): Promise<void> => {
if (!signer || !client) throw new Error('Wallet not connected')
const { rpc, rpcSubscriptions } = client
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send()
const txMsg = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions(instructions, tx),
)
const signed = await signTransactionMessageWithSigners(txMsg)
assertIsTransactionWithBlockhashLifetime(signed)
await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions: rpcSubscriptions as never })(
signed,
{ commitment: 'confirmed' },
)
toast(`Tx confirmed — ${label}`)
for (const key of invalidateKeys) {
queryClient.invalidateQueries({ queryKey: key })
}
}
}
- Step 2: Type-check
cd app && yarn typecheck
- Step 3: Commit
git add app/src/hooks/useTx.ts
git commit -m "feat: useTx hook for on-chain transactions"
Task 20: Listing Write Instructions
Wire create_listing, update_listing, and close_listing into a page-level component. The form handles creation and update via the ?edit=<pk> query param.
Files: app/src/components/CreateListingForm.tsx, app/src/app/listing/create/page.tsx
- Step 1: Create
app/src/components/CreateListingForm.tsx
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useKitTransactionSigner, useWallet } from '@solana/connector/react'
import { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing'
import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import {
getCreateListingInstructionAsync,
getUpdateListingInstruction,
SOLISTING_PROGRAM_ADDRESS,
findListingPda,
} from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
// getCreateListingInstructionAsync / getUpdateListingInstruction names depend on
// codama output — inspect sdk/src/generated/solisting/instructions/ and adjust.
interface FormState {
price: string
quantity: string
oracle: string
metadataUri: string
}
function blank(): FormState {
return { price: '', quantity: '', oracle: '', metadataUri: '' }
}
function SOL_TO_LAMPORTS(sol: string): bigint {
const n = parseFloat(sol)
if (isNaN(n) || n <= 0) throw new Error('Invalid price')
return BigInt(Math.round(n * 1e9))
}
interface Props {
editPk?: Address
}
export function CreateListingForm({ editPk }: Props) {
const router = useRouter()
const { signer } = useKitTransactionSigner()
const { account } = useWallet()
const sendTx = useTx()
const toast = useToast()
const { data: existing } = useListing(editPk ?? ('' as Address))
const [form, setForm] = useState<FormState>(blank())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isUpdate = !!editPk
// Pre-fill from existing listing data on first render
const initialized = useState(false)
if (!initialized[0] && existing && isUpdate) {
initialized[1](true)
const d = existing.data
setForm({
price: (Number(d.price) / 1e9).toString(), // assumes SOL canonical
quantity: String(d.quantity),
oracle: d.canonicalOracle ?? '',
metadataUri: d.metadataUri ?? '',
})
}
function set(key: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [key]: e.target.value }))
}
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!signer || !account) { setError('Connect your wallet first.'); return }
setError(null)
setLoading(true)
try {
if (isUpdate && editPk) {
// update_listing
const ix = getUpdateListingInstruction({
listing: editPk,
seller: signer,
// Pass only changed fields — inspect the generated instruction signature
// and include all required params here:
price: SOL_TO_LAMPORTS(form.price),
quantity: BigInt(form.quantity),
canonicalOracle: form.oracle ? form.oracle as Address : null,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
} else {
// create_listing — listingId is auto-assigned (read from a counter or
// use a client-side incrementing ID stored in localStorage)
const listingId = BigInt(Date.now()) // simple unique ID; program validates uniqueness via PDA
const [listingPda] = await findListingPda(account as Address, listingId)
const ix = await getCreateListingInstructionAsync({
listing: listingPda,
seller: signer,
listingId,
canonicalCurrency: { __kind: 'Sol' } as never,
price: SOL_TO_LAMPORTS(form.price),
quantity: BigInt(form.quantity),
canonicalOracle: form.oracle ? form.oracle as Address : null,
altCurrencies: [],
acceptedResolvers: [],
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings']], 'create_listing')
router.push('/listings')
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
const LABEL_STYLE = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--mut)', marginBottom: 8, letterSpacing: '.04em' } as const
const INPUT_STYLE = { width: '100%', height: 42, padding: '0 13px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, fontSize: 13, outline: 'none' } as const
return (
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<button onClick={() => router.back()} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}>
← Cancel
</button>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{isUpdate ? 'Update Listing' : 'Create Listing'}
</h1>
<p style={{ margin: '6px 0 26px', fontSize: 13, color: 'var(--mut)' }}>
{isUpdate ? `Editing listing ${editPk?.slice(0, 8)}…` : 'New SOL-priced listing on the solisting program'}
</p>
<form onSubmit={submit} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '26px 28px', display: 'flex', flexDirection: 'column', gap: 22 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<label style={LABEL_STYLE}>PRICE (SOL)</label>
<input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT_STYLE} />
</div>
<div>
<label style={LABEL_STYLE}>QUANTITY</label>
<input value={form.quantity} onChange={set('quantity')} placeholder="0" style={INPUT_STYLE} />
</div>
</div>
<div>
<label style={LABEL_STYLE}>CANONICAL ORACLE <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional · Pyth V2 feed)</span></label>
<input value={form.oracle} onChange={set('oracle')} placeholder="Pyth price feed pubkey — leave empty for $1.00 stablecoin" style={INPUT_STYLE} />
</div>
<div>
<label style={LABEL_STYLE}>METADATA URI <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span></label>
<input value={form.metadataUri} onChange={set('metadataUri')} placeholder="ipfs://… or https://…" style={INPUT_STYLE} />
</div>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, padding: '10px 12px', background: 'var(--dangerSoft)', borderRadius: 8 }}>{error}</div>}
<div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--bdSoft)', paddingTop: 20 }}>
<Button type="submit" disabled={loading} style={{ flex: 1, padding: 13, fontSize: 14 }}>
{loading ? 'Sending…' : isUpdate ? 'Update Listing' : 'Create Listing'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()} style={{ padding: '13px 22px', fontSize: 14 }}>
Cancel
</Button>
</div>
</form>
</div>
)
}
Important: After running codama (Task 3 of Part 1), open sdk/src/generated/solisting/instructions/createListing.ts and updateListing.ts to verify the exact parameter names. Common differences: listingId vs listing_id, canonicalCurrency vs canonical_currency, async vs sync variant. Adjust the code above accordingly.
- Step 2: Create
app/src/app/listing/create/page.tsx
'use client'
import { Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { CreateListingForm } from '@/components/CreateListingForm'
import type { Address } from '@solisting/sdk'
function CreateOrEdit() {
const searchParams = useSearchParams()
const editPk = searchParams.get('edit') as Address | null
return <CreateListingForm editPk={editPk ?? undefined} />
}
export default function CreateListingPage() {
return <Suspense><CreateOrEdit /></Suspense>
}
- Step 3: Wire create/update/close handlers in
app/src/app/listing/[pk]/page.tsx
Replace the existing listing/[pk]/page.tsx with:
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { useWallet } from '@solana/connector/react'
import { useKitTransactionSigner } from '@solana/connector/react'
import { ListingDetail } from '@/components/ListingDetail'
import { useTx } from '@/hooks/useTx'
import { getCloseListingInstruction } from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
export default function ListingPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const router = useRouter()
const { account } = useWallet()
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
async function handleClose() {
if (!signer) return
const ix = getCloseListingInstruction({ listing: pk as Address, seller: signer })
await sendTx([ix as never], [['listings'], ['listing', pk]], 'close_listing')
router.push('/listings')
}
return (
<ListingDetail
pk={pk as Address}
walletAddress={account ?? null}
onUpdate={() => router.push(`/listing/create?edit=${pk}`)}
onClose={handleClose}
onPlaceOrder={() => router.push(`/listing/create?order=${pk}`)} // order form TBD — see note
/>
)
}
Note: create_order requires selecting payment currency and calling the oracle if alt currencies are configured. For simplicity, wire onPlaceOrder to a dedicated /order/create?listing=<pk> page if needed, or implement inline in ListingDetail. The create_order instruction is built in Task 21.
- Step 4: Type-check + commit
cd app && yarn typecheck
git add app/src/components/CreateListingForm.tsx app/src/app/listing/create/ app/src/app/listing/
git commit -m "feat: create/update/close listing write transactions"
Task 21: Order Write Instructions
Wire accept_order, reject_order, cancel_order, close_stale_order (solisting) and complete, dispute (descro) into OrderDetail.
Files: Modified app/src/app/order/[pk]/page.tsx
- Step 1: Read the codama-generated order instructions
cat sdk/src/generated/solisting/instructions/acceptOrder.ts
cat sdk/src/generated/solisting/instructions/rejectOrder.ts
cat sdk/src/generated/solisting/instructions/cancelOrder.ts
cat sdk/src/generated/solisting/instructions/closeStaleOrder.ts
Note exact parameter names for each instruction. Required accounts typically include: order, listing, seller or buyer (as signer), escrowAccount, descroProgram.
Also check @descro/sdk for getCompleteInstruction, getDisputeInstruction, getResolveInstruction.
- Step 2: Replace
app/src/app/order/[pk]/page.tsx
'use client'
import { use } from 'react'
import { useWallet, useKitTransactionSigner } from '@solana/connector/react'
import { OrderDetail } from '@/components/OrderDetail'
import { useTx } from '@/hooks/useTx'
import { useOrder } from '@/hooks/useOrder'
import {
getAcceptOrderInstruction,
getRejectOrderInstruction,
getCancelOrderInstruction,
getCloseStaleOrderInstruction,
} from '@solisting/sdk'
import { getCompleteInstruction, getDisputeInstruction } from '@descro/sdk'
import type { Address } from '@solisting/sdk'
export default function OrderPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const { account } = useWallet()
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
const { data } = useOrder(pk as Address)
const invalidate = [['order', pk], ['listings']]
async function handleAccept() {
if (!signer || !data) return
const od = data.order.data
const ix = getAcceptOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
seller: signer,
escrowAccount: od.escrowAccount,
// descroProgram: DESCRO_PROGRAM_ADDRESS — if required by the instruction
})
await sendTx([ix as never], invalidate, 'accept_order')
}
async function handleReject() {
if (!signer || !data) return
const od = data.order.data
const ix = getRejectOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
seller: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'reject_order')
}
async function handleCancel() {
if (!signer || !data) return
const od = data.order.data
const ix = getCancelOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
buyer: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'cancel_order')
}
async function handleCloseStale() {
if (!signer || !data) return
const od = data.order.data
const ix = getCloseStaleOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
payer: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'close_stale_order')
}
async function handleComplete() {
if (!signer || !data) return
const od = data.order.data
const ix = getCompleteInstruction({
escrow: od.escrowAccount,
buyer: signer,
})
await sendTx([ix as never], [['order', pk]], 'complete')
}
async function handleDispute() {
if (!signer || !data) return
const od = data.order.data
const ix = getDisputeInstruction({
escrow: od.escrowAccount,
raiser: signer,
})
await sendTx([ix as never], [['order', pk]], 'dispute')
}
return (
<OrderDetail
pk={pk as Address}
walletAddress={account ?? null}
onAccept={handleAccept}
onReject={handleReject}
onCancel={handleCancel}
onCloseStale={handleCloseStale}
onComplete={handleComplete}
onDispute={handleDispute}
/>
)
}
Important: The exact parameter names and required accounts for each instruction come from codama output. After reading the generated files in Step 1, adjust every instruction call to match. Common required accounts: listing, order, escrowAccount, descroProgram (as a programId or regular address arg), plus the signer (seller, buyer, or payer).
- Step 3: Type-check
cd app && yarn typecheck
Fix any instruction parameter mismatches by re-reading the codama-generated instruction files.
- Step 4: Commit
git add app/src/app/order/
git commit -m "feat: order write instructions (accept/reject/cancel/close-stale/complete/dispute)"
Task 22: create_order Instruction
The create_order instruction requires:
- The buyer's chosen payment currency (SOL or an alt currency from
listing.altCurrencies) - The
oracleaccount keys from the listing (passed verbatim:listing.canonicalOracle/alt.usdOracle) - The derived
escrowId(deriveEscrowId(orderPda)) - An
expectedAmount+maxSlippageBpsfor oracle slippage check (pass 0 for no slippage check)
For now, only SOL payment (canonical currency = Sol, no oracle) is fully supported in the UI, since SPL is rejected by the program with SplNotImplemented.
- Step 1: Add place-order flow to
listing/[pk]/page.tsx
Replace the onPlaceOrder stub with an inline SOL order:
// Add to imports in listing/[pk]/page.tsx:
import {
getCreateOrderInstructionAsync,
findOrderPda,
deriveEscrowId,
} from '@solisting/sdk'
import { DESCRO_PROGRAM_ADDRESS } from '@descro/sdk'
import { useListing } from '@/hooks/useListing'
// Inside the component (add these):
const { data: listing } = useListing(pk as Address)
const [orderLoading, setOrderLoading] = useState(false)
async function handlePlaceOrder() {
if (!signer || !account || !listing) return
setOrderLoading(true)
try {
const orderId = BigInt(Date.now()) // unique per buyer; program validates via PDA
const [orderPda] = await findOrderPda(pk as Address, account as Address, orderId)
const escrowId = deriveEscrowId(orderPda)
const ix = await getCreateOrderInstructionAsync({
order: orderPda,
listing: pk as Address,
buyer: signer,
// canonicalOracle: listing.data.canonicalOracle ?? null,
paymentCurrency: { __kind: 'Sol' } as never,
orderId,
escrowId,
expectedAmount: listing.data.price,
maxSlippageBps: 0,
descroProgram: DESCRO_PROGRAM_ADDRESS,
// additional accounts from codama (escrowAccount PDA, vault, etc.)
})
await sendTx([ix as never], [['listing', pk], ['orders', pk]], 'create_order')
} catch (err) {
// toast is called by sendTx on success; show error here
console.error(err)
} finally {
setOrderLoading(false)
}
}
Then pass onPlaceOrder={handlePlaceOrder} to <ListingDetail>.
Important: getCreateOrderInstructionAsync may require additional accounts like the escrowAccount PDA (from descro), the vault PDA, and the descro program itself. After reviewing the codama-generated createOrder.ts, pass all required accounts. The descro SDK exports helpers for finding these PDAs.
- Step 2: Type-check
cd app && yarn typecheck
- Step 3: Commit
git add app/src/app/listing/
git commit -m "feat: create_order instruction (SOL canonical currency)"
Task 23: Dashboard
Files: app/src/hooks/useMyListings.ts, app/src/hooks/useMyOrders.ts, app/src/components/Dashboard.tsx, app/src/app/dashboard/page.tsx
- Step 1: Create
app/src/hooks/useMyListings.ts
import { useWallet } from '@solana/connector/react'
import { useListings } from './useListings'
export function useMyListings() {
const { account } = useWallet()
const { data: all = [], ...rest } = useListings()
return { data: all.filter((l) => l.data.seller === account), ...rest }
}
- Step 2: Create
app/src/hooks/useMyOrders.ts
import { useQuery } from '@tanstack/react-query'
import { useCluster, useWallet } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrdersByBuyer } from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
export function useMyOrders() {
const { account } = useWallet()
const { cluster } = useCluster()
return useQuery({
queryKey: ['myOrders', account, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchOrdersByBuyer(rpc, account as Address)
},
enabled: !!account && !!cluster,
refetchInterval: 30_000,
})
}
- Step 3: Create
app/src/components/Dashboard.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useWallet } from '@solana/connector/react'
import { useMyListings } from '@/hooks/useMyListings'
import { useMyOrders } from '@/hooks/useMyOrders'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { abbrev, fmtSol } from '@/lib/format'
export function Dashboard() {
const router = useRouter()
const { account } = useWallet()
const [tab, setTab] = useState<'listings' | 'orders'>('listings')
const { data: myListings = [], isLoading: loadingL } = useMyListings()
const { data: myOrders = [], isLoading: loadingO } = useMyOrders()
if (!account) {
return (
<div style={{ maxWidth: 420, margin: '80px auto', textAlign: 'center', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '40px 32px' }}>
<div style={{ width: 48, height: 48, borderRadius: 14, background: 'var(--accSoft)', border: '1px solid var(--accBd)', margin: '0 auto 18px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 16, height: 16, borderRadius: 5, background: 'linear-gradient(135deg, var(--acc), var(--acc2))' }} />
</div>
<h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 700 }}>Wallet required</h2>
<p style={{ margin: '0 0 22px', fontSize: 13.5, color: 'var(--mut)', lineHeight: 1.55 }}>
Connect a wallet to view your listings and orders, and to execute instructions you're authorized for.
</p>
</div>
)
}
return (
<div>
<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' }}>My Dashboard</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--mut)' }}>{abbrev(account)}</p>
</div>
<Button onClick={() => router.push('/listing/create')}>+ Create Listing</Button>
</div>
<div style={{ display: 'flex', gap: 4, background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 11, padding: 4, width: 'max-content', marginBottom: 20 }}>
{[
{ key: 'listings' as const, label: `My Listings · ${myListings.length}` },
{ key: 'orders' as const, label: `My Orders · ${myOrders.length}` },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
style={{ padding: '9px 18px', borderRadius: 8, fontSize: 13, fontWeight: 600, color: tab === t.key ? 'var(--tx)' : 'var(--mut)', background: tab === t.key ? 'var(--bg3)' : 'transparent' }}
>
{t.label}
</button>
))}
</div>
{tab === 'listings' && (
<div>
{loadingL && <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>}
{!loadingL && myListings.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)' }}>
You have no listings yet.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{myListings.map((l) => {
const sc = STATUS_COLORS[l.data.isActive ? 'active' : 'inactive']
const avail = Number(l.data.quantity) - Number(l.data.quantityReserved)
return (
<div key={l.address} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '18px 20px', display: 'flex', alignItems: 'center', gap: 18 }}>
<div onClick={() => router.push(`/listing/${l.address}`)} style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 600, fontSize: 15 }}>{l.data.metadataUri || abbrev(l.address)}</span>
<Badge color={sc.color} bg={sc.bg}>{l.data.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
<div style={{ fontSize: 12, color: 'var(--mut)', marginTop: 4, fontFamily: 'var(--font-mono)' }}>
#{String(l.data.listingId)} · {fmtSol(l.data.price)} · {avail}/{String(l.data.quantity)} avail · {String(l.data.quantityReserved)} pending
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button variant="ghost" style={{ padding: '8px 14px', fontSize: 12.5 }} onClick={() => router.push(`/listing/create?edit=${l.address}`)}>Update</Button>
<Button variant="danger" style={{ padding: '8px 14px', fontSize: 12.5 }} onClick={() => router.push(`/listing/${l.address}`)}>Close</Button>
</div>
</div>
)
})}
</div>
</div>
)}
{tab === 'orders' && (
<div>
{loadingO && <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>}
{!loadingO && myOrders.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)' }}>
You have no orders as a buyer.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{myOrders.map((o) => (
<div
key={o.address}
onClick={() => router.push(`/order/${o.address}`)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '18px 20px', display: 'flex', alignItems: 'center', gap: 18, cursor: 'pointer' }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{abbrev(o.data.listingAccount)}</div>
<div style={{ fontSize: 12, color: 'var(--mut)', marginTop: 4, fontFamily: 'var(--font-mono)' }}>
#{String(o.data.orderId)} · {fmtSol(o.data.amount)}
</div>
</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '5px 13px', borderRadius: 999, background: 'var(--bg3)', color: 'var(--mut)', flexShrink: 0 }}>
#{String(o.data.escrowId)}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
- Step 4: Create
app/src/app/dashboard/page.tsx
import { Dashboard } from '@/components/Dashboard'
export default function DashboardPage() { return <Dashboard /> }
- Step 5: Type-check + commit
cd app && yarn typecheck
git add app/src/hooks/useMyListings.ts app/src/hooks/useMyOrders.ts app/src/components/Dashboard.tsx app/src/app/dashboard/
git commit -m "feat: dashboard with my listings and my orders"
Task 24: Final Wiring + Smoke Test
- Step 1: Full type-check
cd app && yarn typecheck
Expected: zero errors. If there are errors due to codama-generated type shapes (e.g. __kind vs a different discriminant, or field names that differ), fix them by re-inspecting the generated files in sdk/src/generated/.
- Step 2: Build
cd app && yarn build
Expected: successful Next.js production build. Fix any remaining type or import errors.
- Step 3: Run dev server + smoke test each route
cd app && yarn dev
Visit and verify each route renders without crashing:
http://localhost:3000/→ redirects to/listingshttp://localhost:3000/listings→ table (empty or with data from devnet)http://localhost:3000/escrows→ escrow tablehttp://localhost:3000/resolvers→ resolver gridhttp://localhost:3000/dashboard→ wallet-required prompt (if disconnected)http://localhost:3000/listing/create→ form rendershttp://localhost:3000/search?q=test→ "No account found" page
Connect a wallet on devnet and verify:
-
Dashboard shows wallet address and tabs
-
"Create Listing" button navigates to the form
-
Form submits (requires SOL on devnet — use faucet at
https://faucet.solana.com) -
Step 4: Final commit
git add -A
git commit -m "feat: complete Solisting Explorer app"
Post-Implementation Notes
Codama field name adjustments: Throughout this plan, field names like __kind, canonicalCurrency, quantityReserved, escrowAccount, ruledForBuyer are assumed based on Rust → camelCase conventions. After running codama (Part 1 Task 3), always verify against the generated files before trusting the code above.
od.seller in OrderDetail: The OrderAccount may not store a seller field directly — it stores listingAccount. Fetch the seller from the ListingAccount or from the descro EscrowAccount (ed.seller) for display and role detection.
SPL currency: Currency::Spl is rejected by the program (SplNotImplemented). The form only supports SOL as canonical currency for now. The alt-currencies UI is read-only (displays configured alts but doesn't build a create_order tx with SPL payment).
listingId for create_listing: BigInt(Date.now()) gives a millisecond-precision unique ID. The program validates uniqueness only via the PDA seeds, so collisions within the same millisecond for the same seller would fail on-chain. This is fine for a UI but production apps should track the next ID more carefully.
DESCRO_PROGRAM_ADDRESS: Exported from @descro/sdk. Verify with grep -r DESCRO_PROGRAM_ADDRESS descro/sdk/src/generated/. Pass it to any solisting instruction that CPIs into descro (notably create_order, accept_order, reject_order, cancel_order).