docs: add 4-part Solisting Explorer implementation plan
This commit is contained in:
@@ -0,0 +1,837 @@
|
||||
# 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`**
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
cd app && yarn typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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`**
|
||||
|
||||
```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`**
|
||||
|
||||
```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:
|
||||
|
||||
```tsx
|
||||
'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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`**
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
cd app && yarn typecheck
|
||||
```
|
||||
|
||||
Fix any instruction parameter mismatches by re-reading the codama-generated instruction files.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
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 `oracle` account keys from the listing (passed verbatim: `listing.canonicalOracle` / `alt.usdOracle`)
|
||||
- The derived `escrowId` (`deriveEscrowId(orderPda)`)
|
||||
- An `expectedAmount` + `maxSlippageBps` for 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:
|
||||
|
||||
```tsx
|
||||
// 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**
|
||||
|
||||
```bash
|
||||
cd app && yarn typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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`**
|
||||
|
||||
```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`**
|
||||
|
||||
```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`**
|
||||
|
||||
```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`**
|
||||
|
||||
```tsx
|
||||
import { Dashboard } from '@/components/Dashboard'
|
||||
export default function DashboardPage() { return <Dashboard /> }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Type-check + commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
cd app && yarn dev
|
||||
```
|
||||
|
||||
Visit and verify each route renders without crashing:
|
||||
- `http://localhost:3000/` → redirects to `/listings`
|
||||
- `http://localhost:3000/listings` → table (empty or with data from devnet)
|
||||
- `http://localhost:3000/escrows` → escrow table
|
||||
- `http://localhost:3000/resolvers` → resolver grid
|
||||
- `http://localhost:3000/dashboard` → wallet-required prompt (if disconnected)
|
||||
- `http://localhost:3000/listing/create` → form renders
|
||||
- `http://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**
|
||||
|
||||
```bash
|
||||
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`).
|
||||
Reference in New Issue
Block a user