feat: resolvers views and search fallback page
This commit is contained in:
8
app/src/app/resolver/[pk]/page.tsx
Normal file
8
app/src/app/resolver/[pk]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client'
|
||||||
|
import { use } from 'react'
|
||||||
|
import { ResolverDetail } from '@/components/ResolverDetail'
|
||||||
|
import type { Address } from '@solisting/sdk'
|
||||||
|
export default function ResolverPage({ params }: { params: Promise<{ pk: string }> }) {
|
||||||
|
const { pk } = use(params)
|
||||||
|
return <ResolverDetail pk={pk as Address} />
|
||||||
|
}
|
||||||
2
app/src/app/resolvers/page.tsx
Normal file
2
app/src/app/resolvers/page.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { ResolverGrid } from '@/components/ResolverGrid'
|
||||||
|
export default function ResolversPage() { return <ResolverGrid /> }
|
||||||
20
app/src/app/search/page.tsx
Normal file
20
app/src/app/search/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
function SearchContent() {
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const q = params.get('q') ?? ''
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 520, margin: '80px auto', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 12 }}>GLOBAL SEARCH</div>
|
||||||
|
<h1 style={{ margin: '0 0 10px', fontSize: 24, fontWeight: 700 }}>No account found</h1>
|
||||||
|
<p style={{ margin: '0 0 8px', fontSize: 14, color: 'var(--mut)', lineHeight: 1.55 }}>Nothing on-chain matched this query on the selected network.</p>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--acc2light)', wordBreak: 'break-all', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 10, padding: '12px 16px', margin: '18px 0 24px' }}>{q}</div>
|
||||||
|
<button onClick={() => router.push('/listings')} style={{ padding: '11px 20px', borderRadius: 11, background: 'var(--bg3)', border: '1px solid var(--bd)', fontWeight: 600, fontSize: 13 }}>Back to Listings</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default function SearchPage() {
|
||||||
|
return <Suspense><SearchContent /></Suspense>
|
||||||
|
}
|
||||||
67
app/src/components/ResolverDetail.tsx
Normal file
67
app/src/components/ResolverDetail.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { ResolverType, AcceptancePolicy } from '@descro/sdk'
|
||||||
|
import { useResolvers } from '@/hooks/useResolvers'
|
||||||
|
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
|
||||||
|
import { abbrev } from '@/lib/format'
|
||||||
|
import type { Address } from '@solisting/sdk'
|
||||||
|
|
||||||
|
export function ResolverDetail({ pk }: { pk: Address }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: resolvers = [], isLoading } = useResolvers()
|
||||||
|
const r = resolvers.find((x) => x.pda === pk)
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
|
||||||
|
if (!r) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Resolver entry not found.</div>
|
||||||
|
|
||||||
|
const d = r.account
|
||||||
|
const total = Number(d.ruledForBuyer) + Number(d.ruledForSeller)
|
||||||
|
const buyerPct = total > 0 ? Math.round(Number(d.ruledForBuyer) / total * 100) : 0
|
||||||
|
const sellerPct = total > 0 ? 100 - buyerPct : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => router.push('/resolvers')} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}>
|
||||||
|
← Resolvers
|
||||||
|
</button>
|
||||||
|
<div style={{ marginBottom: 8, fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)' }}>RESOLVER ENTRY · {abbrev(pk)}</div>
|
||||||
|
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>{d.name || abbrev(pk)}</h1>
|
||||||
|
{d.description && <p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--mut)', maxWidth: 620, lineHeight: 1.55 }}>{d.description}</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 18, alignItems: 'start' }}>
|
||||||
|
<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' }}>ACCOUNT FIELDS</div>
|
||||||
|
<FieldRow label="Authority"><MonoChip value={abbrev(d.authority)} onCopy={() => navigator.clipboard.writeText(d.authority)} /></FieldRow>
|
||||||
|
<FieldRow label="Type"><span style={{ fontSize: 13, fontWeight: 600 }}>{ResolverType[d.resolverType as number] as string}</span></FieldRow>
|
||||||
|
<FieldRow label="Policy"><span style={{ fontSize: 13 }}>{AcceptancePolicy[d.acceptancePolicy as number] as string}</span></FieldRow>
|
||||||
|
<FieldRow label="Fee"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{(d.feeBps / 100).toFixed(2)}%</span></FieldRow>
|
||||||
|
<FieldRow label="Fee Recipient"><MonoChip value={abbrev(d.feeRecipient)} onCopy={() => navigator.clipboard.writeText(d.feeRecipient)} /></FieldRow>
|
||||||
|
{d.metadataUri && <FieldRow label="Metadata"><a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ fontSize: 12.5, color: 'var(--acc2light)', wordBreak: 'break-all' }}>{d.metadataUri}</a></FieldRow>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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: 16 }}>DISPUTE STATISTICS</div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 18 }}>
|
||||||
|
<div style={{ fontSize: 34, fontWeight: 700 }}>{total}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--mut)' }}>total resolved</div>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ label: 'Ruled for buyer', count: Number(d.ruledForBuyer), pct: buyerPct, color: 'var(--acc2)', barColor: 'var(--acc2)' },
|
||||||
|
{ label: 'Ruled for seller', count: Number(d.ruledForSeller), pct: sellerPct, color: '#F5B23E', barColor: '#F5B23E' },
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12.5, marginBottom: 6 }}>
|
||||||
|
<span style={{ color: stat.color, fontWeight: 600 }}>{stat.label}</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{stat.count} · {stat.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, borderRadius: 5, background: 'var(--bg3)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${stat.pct}%`, background: stat.barColor }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
app/src/components/ResolverGrid.tsx
Normal file
60
app/src/components/ResolverGrid.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { ResolverType, AcceptancePolicy } from '@descro/sdk'
|
||||||
|
import { useResolvers } from '@/hooks/useResolvers'
|
||||||
|
import { abbrev } from '@/lib/format'
|
||||||
|
|
||||||
|
export function ResolverGrid() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: resolvers = [], isLoading } = useResolvers()
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>Resolver Registry</h1>
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: 13, color: 'var(--mut)' }}>
|
||||||
|
{resolvers.length} ResolverEntry PDAs · descro_ext_resolvers program
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
{resolvers.map((r) => {
|
||||||
|
const d = r.account
|
||||||
|
const total = Number(d.ruledForBuyer) + Number(d.ruledForSeller)
|
||||||
|
const buyerPct = total > 0 ? Math.round(Number(d.ruledForBuyer) / total * 100) : 0
|
||||||
|
const sellerPct = total > 0 ? 100 - buyerPct : 0
|
||||||
|
const feePct = (d.feeBps / 100).toFixed(2)
|
||||||
|
const resolverTypeName = ResolverType[d.resolverType as number] as string
|
||||||
|
const policyName = AcceptancePolicy[d.acceptancePolicy as number] as string
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.pda}
|
||||||
|
onClick={() => router.push(`/resolver/${r.pda}`)}
|
||||||
|
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '20px 22px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700 }}>{d.name || abbrev(r.pda)}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--mut)', marginTop: 3 }}>{abbrev(r.pda)}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ padding: '4px 11px', borderRadius: 999, fontSize: 11, fontWeight: 600, color: 'var(--acc2light)', background: 'var(--accSoft)', flexShrink: 0 }}>{feePct}% fee</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ padding: '4px 10px', borderRadius: 7, fontSize: 11, fontWeight: 600, background: 'var(--bg3)', color: 'var(--mut)' }}>{resolverTypeName}</span>
|
||||||
|
<span style={{ padding: '4px 10px', borderRadius: 7, fontSize: 11, fontWeight: 600, background: 'var(--bg3)', color: 'var(--mut)' }}>{policyName}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, textAlign: 'center', borderTop: '1px solid var(--bdSoft)', paddingTop: 14 }}>
|
||||||
|
<div><div style={{ fontSize: 18, fontWeight: 700 }}>{total}</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>resolved</div></div>
|
||||||
|
<div><div style={{ fontSize: 18, fontWeight: 700, color: 'var(--acc2light)' }}>{buyerPct}%</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>for buyer</div></div>
|
||||||
|
<div><div style={{ fontSize: 18, fontWeight: 700, color: '#F5B23E' }}>{sellerPct}%</div><div style={{ fontSize: 10.5, color: 'var(--mut)', marginTop: 2 }}>for seller</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{resolvers.length === 0 && <div style={{ gridColumn: '1/-1', padding: 48, textAlign: 'center', color: 'var(--mut)' }}>No resolvers registered.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
app/src/hooks/useResolvers.ts
Normal file
17
app/src/hooks/useResolvers.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useCluster } from '@solana/connector/react'
|
||||||
|
import { createSolanaRpc } from '@solana/kit'
|
||||||
|
import { fetchAllResolvers } from '@descro/sdk'
|
||||||
|
|
||||||
|
export function useResolvers() {
|
||||||
|
const { cluster } = useCluster()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['resolvers', cluster?.id],
|
||||||
|
queryFn: () => {
|
||||||
|
const rpc = createSolanaRpc(cluster!.url)
|
||||||
|
return fetchAllResolvers(rpc as Parameters<typeof fetchAllResolvers>[0])
|
||||||
|
},
|
||||||
|
enabled: !!cluster,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user