32 KiB
Solisting App — Foundation (Part 2 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: Scaffold the Next.js 16 App Router project with all shared infrastructure: package setup, CSS theming, wallet providers, React Query, Tailwind (for connectorkit components), copied connectorkit ui-base and connector components, shared UI primitives, the Nav, and the root layout.
Architecture: All pages are 'use client'. CSS custom properties drive the Nebula dark theme; inline styles use these variables. Tailwind v4 (PostCSS plugin only) is included for the copied connectorkit components. The WalletProviders component wraps AppProvider (from @solana/connector/react) and QueryClientProvider (React Query).
Tech Stack: Next.js 16, React 19, @solana/connector ^0.2.4, @solana/kit ^6, @tanstack/react-query ^5, @base-ui/react ^1, class-variance-authority, clsx, tailwind-merge, lucide-react, Tailwind v4 via @tailwindcss/postcss.
Prerequisites: Part 1 (SDK) complete; sdk/ package built; yarn install run at workspace root.
File Map
| Path | Purpose |
|---|---|
app/package.json |
App package with all deps |
app/tsconfig.json |
TS config with @/* path alias |
app/next.config.ts |
Minimal Next.js config |
app/postcss.config.mjs |
Tailwind v4 PostCSS plugin |
app/src/styles/globals.css |
Nebula CSS variables + Tailwind import + fonts |
app/src/lib/utils.ts |
cn() helper (clsx + tailwind-merge) |
app/src/lib/format.ts |
abbrev, fmtSol, fmtTok, relTime, exact |
app/src/lib/queryClient.ts |
React Query QueryClient singleton |
app/src/providers/WalletProviders.tsx |
AppProvider + QueryClientProvider |
app/src/components/ui-base/button.tsx |
Copied from connectorkit (ui-base) |
app/src/components/ui-base/menu.tsx |
Copied from connectorkit (ui-base) |
app/src/components/ui-base/dialog.tsx |
Copied from connectorkit (ui-base) |
app/src/components/ui-base/collapsible.tsx |
Copied from connectorkit (ui-base) |
app/src/components/connector/connect-button.tsx |
Copied from connectorkit (adapted) |
app/src/components/connector/wallet-modal.tsx |
Copied from connectorkit (no WC/QR) |
app/src/components/connector/wallet-dropdown-content.tsx |
Simplified dropdown |
app/src/components/ui/Button.tsx |
App button primitive (inline styles) |
app/src/components/ui/Badge.tsx |
Status pill |
app/src/components/ui/FieldRow.tsx |
Label + value row |
app/src/components/ui/Toast.tsx |
Toast context + fixed notification |
app/src/components/Nav.tsx |
Sticky navbar |
app/src/app/layout.tsx |
Root layout (fonts, providers, toast) |
app/src/app/page.tsx |
Redirect to /listings |
Task 6: App Package Scaffolding
Files: app/package.json, app/tsconfig.json, app/next.config.ts, app/postcss.config.mjs
- Step 1: Create
app/package.json
{
"name": "solisting-app",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@descro/sdk": "file:../../descro/sdk",
"@solana/connector": "^0.2.4",
"@solana/connector-debugger": "^0.1.1",
"@solana/kit": "^6.0.0",
"@solisting/sdk": "workspace:*",
"@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"next": "^16.2.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.14",
"typescript": "^6.0.3"
}
}
- Step 2: Create
app/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
- Step 3: Create
app/next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
reactStrictMode: true,
}
export default config
- Step 4: Create
app/postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
- Step 5: Install dependencies
yarn install
Expected: app/node_modules/ populated with all deps including @solisting/sdk resolved from the local workspace.
- Step 6: Commit
git add app/package.json app/tsconfig.json app/next.config.ts app/postcss.config.mjs
git commit -m "feat: scaffold solisting-app Next.js package"
Task 7: CSS Globals + format.ts + utils.ts + queryClient.ts
Files: app/src/styles/globals.css, app/src/lib/utils.ts, app/src/lib/format.ts, app/src/lib/queryClient.ts
- Step 1: Create
app/src/styles/globals.css
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
--bg: oklch(0.165 0.022 282);
--bg2: oklch(0.205 0.026 282);
--bg3: oklch(0.25 0.03 283);
--bd: oklch(0.33 0.032 286);
--bdSoft: oklch(0.27 0.028 284);
--navbg: oklch(0.18 0.024 282 / .82);
--tx: oklch(0.96 0.008 285);
--mut: oklch(0.68 0.022 284);
--radius: 13px;
--acc: #9945FF;
--acc2: #14F195;
--acc2light: #7df5c4;
--accGlow: rgba(153,69,255,.55);
--accSoft: rgba(153,69,255,.13);
--accBd: rgba(153,69,255,.4);
--danger: #FF7A59;
--dangerSoft: rgba(255,122,89,.12);
--font-body: 'Space Grotesk', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* Tailwind theme tokens for connectorkit components */
@theme {
--color-primary: var(--acc);
--color-primary-foreground: #0b0613;
--color-background: var(--bg);
--color-foreground: var(--tx);
--color-popover: var(--bg2);
--color-popover-foreground: var(--tx);
--color-border: var(--bd);
--color-input: var(--bd);
--color-muted: var(--bg3);
--color-accent: var(--bg3);
--color-accent-foreground: var(--tx);
--color-destructive: var(--danger);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--tx);
font-family: var(--font-body);
}
input, button, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
button { cursor: pointer; border: none; background: none; }
::selection { background: rgba(153,69,255,.35); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.13); border-radius: 8px; }
::-webkit-scrollbar-track { background: transparent; }
@keyframes scToast { from { opacity: 0; transform: translate(-50%,10px); } to { opacity: 1; transform: translate(-50%,0); } }
@keyframes scSpin { to { transform: rotate(360deg); } }
@keyframes scPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
- Step 2: Create
app/src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
- Step 3: Create
app/src/lib/format.ts
export function abbrev(pk: string | null | undefined): string {
if (!pk) return '—'
return `${pk.slice(0, 4)}…${pk.slice(-4)}`
}
export function fmtSol(lamports: bigint | number): string {
const v = Number(lamports) / 1e9
return v.toLocaleString(undefined, { maximumFractionDigits: 4 }) + ' SOL'
}
export function fmtTok(raw: bigint | number, decimals: number, symbol: string): string {
const v = Number(raw) / Math.pow(10, decimals)
return v.toLocaleString(undefined, { maximumFractionDigits: Math.min(decimals, 2) }) + ' ' + symbol
}
export function relTime(ts: number): string {
const d = Math.floor(Date.now() / 1000) - ts
if (d < 60) return `${d}s ago`
if (d < 3600) return `${Math.floor(d / 60)}m ago`
if (d < 86400) return `${Math.floor(d / 3600)}h ago`
return `${Math.floor(d / 86400)}d ago`
}
export function exact(ts: number): string {
return new Date(ts * 1000).toUTCString()
}
export function priceStr(
currency: { __kind: 'Sol' } | { __kind: 'Spl'; decimals: number; symbol?: string },
price: bigint | number,
): string {
if (currency.__kind === 'Sol') return fmtSol(price)
return fmtTok(price, currency.decimals, (currency as { symbol?: string }).symbol ?? 'SPL')
}
Note: The __kind field is how codama renders Rust enums in TypeScript. After running Codama (Task 3), inspect src/generated/solisting/types/currency.ts to confirm the exact shape and adjust priceStr if needed.
- Step 4: Create
app/src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
gcTime: 5 * 60 * 1000,
retry: 2,
},
},
})
- Step 5: Type-check
cd app && yarn typecheck
Expected: No errors (or only missing Next.js module errors before next-env.d.ts is generated — run next dev once if needed).
- Step 6: Commit
git add app/src/
git commit -m "feat: add CSS globals, format utils, and queryClient"
Task 8: WalletProviders
Files: app/src/providers/WalletProviders.tsx
- Step 1: Create
app/src/providers/WalletProviders.tsx
'use client'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import { AppProvider, getDefaultConfig, getDefaultMobileConfig } from '@solana/connector/react'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/queryClient'
function getOrigin() {
if (typeof window !== 'undefined') return window.location.origin
return 'http://localhost:3000'
}
export function WalletProviders({ children }: { children: ReactNode }) {
const connectorConfig = useMemo(
() =>
getDefaultConfig({
appName: 'Solisting Explorer',
appUrl: getOrigin(),
autoConnect: true,
clusters: [
{ id: 'solana:localnet' as const, label: 'Localnet', url: 'http://localhost:8899' },
{ id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' },
{
id: 'solana:mainnet' as const,
label: 'Mainnet',
url: 'https://api.mainnet-beta.solana.com',
},
],
}),
[],
)
const mobile = useMemo(
() => getDefaultMobileConfig({ appName: 'Solisting Explorer', appUrl: getOrigin() }),
[],
)
return (
<AppProvider connectorConfig={connectorConfig} mobile={mobile}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</AppProvider>
)
}
- Step 2: Type-check
cd app && yarn typecheck
- Step 3: Commit
git add app/src/providers/
git commit -m "feat: add WalletProviders with connector + react-query"
Task 9: Copy Connectorkit ui-base Components
Fetch the four ui-base wrapper components directly from the connectorkit GitHub repo and write them into app/src/components/ui-base/.
- Step 1: Fetch and write
button.tsx
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/ui-base/button.tsx \
--jq '.content' | base64 -d > app/src/components/ui-base/button.tsx
- Step 2: Fetch and write
menu.tsx
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/ui-base/menu.tsx \
--jq '.content' | base64 -d > app/src/components/ui-base/menu.tsx
- Step 3: Fetch and write
dialog.tsx
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/ui-base/dialog.tsx \
--jq '.content' | base64 -d > app/src/components/ui-base/dialog.tsx
- Step 4: Fetch and write
collapsible.tsx
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/ui-base/collapsible.tsx \
--jq '.content' | base64 -d > app/src/components/ui-base/collapsible.tsx
- Step 5: Fix imports in all four files
All four files import from @/lib/utils. That path is already correct since we have app/src/lib/utils.ts. No change needed.
Run a quick check:
grep -r "from '@/" app/src/components/ui-base/
Expected: Only @/lib/utils imports. If you see other @/components/... imports, those files also need to be copied.
- Step 6: Type-check
cd app && yarn typecheck
Fix any import errors by checking if additional dependencies are missing from package.json.
- Step 7: Commit
git add app/src/components/ui-base/
git commit -m "feat: copy connectorkit ui-base components"
Task 10: Copy and Adapt Connector Components
Fetch connect-button.tsx and wallet-modal.tsx from connectorkit. Adapt wallet-modal.tsx to remove WalletConnect/QR code (heavy dep, not needed). Write a simplified wallet-dropdown-content.tsx matching the prototype (address + copy + disconnect).
- Step 1: Fetch
connect-button.tsx
mkdir -p app/src/components/connector
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/connector/base-ui/connect-button.tsx \
--jq '.content' | base64 -d > app/src/components/connector/connect-button.tsx
- Step 2: Fetch
wallet-modal.tsxand strip WalletConnect
gh api repos/solana-foundation/connectorkit/contents/examples/next-js/components/connector/base-ui/wallet-modal.tsx \
--jq '.content' | base64 -d > app/src/components/connector/wallet-modal.tsx
Then open wallet-modal.tsx and:
-
Remove
import { CustomQRCode } from '@/components/ui/custom-qr-code' -
Remove
walletConnectUriandonClearWalletConnectUrifrom the props interface and destructuring -
Remove the entire QR code section (the
{walletConnectUri && ...}block) -
Remove the
walletConnectUri?: string | nullandonClearWalletConnectUri?: () => voidfromWalletModalProps -
Step 3: Update
connect-button.tsxto remove WalletConnect props
In connect-button.tsx, the WalletModal is called with walletConnectUri and onClearWalletConnectUri props. Remove these:
// Before:
<WalletModal
open={isModalOpen}
onOpenChange={open => {
setIsModalOpen(open);
if (!open) { clearWalletConnectUri(); }
}}
walletConnectUri={walletConnectUri}
onClearWalletConnectUri={clearWalletConnectUri}
/>
// After:
<WalletModal open={isModalOpen} onOpenChange={setIsModalOpen} />
Also remove walletConnectUri and clearWalletConnectUri from the useConnector() destructure.
- Step 4: Write simplified
wallet-dropdown-content.tsx
Replace whatever was fetched with this version matching the prototype's dropdown:
'use client'
import { useConnector } from '@solana/connector/react'
import { useState } from 'react'
interface Props {
selectedAccount: string
walletIcon?: string
walletName: string
}
export function WalletDropdownContent({ selectedAccount, walletIcon, walletName }: Props) {
const { disconnectWallet } = useConnector()
const [copied, setCopied] = useState(false)
function copyAddress() {
navigator.clipboard.writeText(selectedAccount)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<div
style={{
width: 230,
background: 'var(--bg2)',
border: '1px solid var(--bd)',
borderRadius: 12,
padding: 12,
}}
>
{walletIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<img src={walletIcon} width={20} height={20} alt={walletName} style={{ borderRadius: 4 }} />
<span style={{ fontSize: 13, fontWeight: 600 }}>{walletName}</span>
</div>
)}
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 6 }}>
CONNECTED WALLET
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: 12,
wordBreak: 'break-all',
lineHeight: 1.5,
color: 'var(--tx)',
marginBottom: 10,
}}
>
{selectedAccount}
</div>
<button
onClick={copyAddress}
style={{
width: '100%',
marginBottom: 6,
padding: 8,
borderRadius: 8,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
fontSize: 12,
fontWeight: 500,
}}
>
{copied ? 'Copied!' : 'Copy address'}
</button>
<button
onClick={() => disconnectWallet()}
style={{
width: '100%',
padding: 8,
borderRadius: 8,
background: 'transparent',
border: '1px solid var(--danger)',
fontSize: 12,
fontWeight: 500,
color: 'var(--danger)',
}}
>
Disconnect
</button>
</div>
)
}
- Step 5: Type-check
cd app && yarn typecheck
Fix any remaining import issues (e.g. if connect-button.tsx imports from @/components/connector/shared/hidden-wallet-icons, fetch that file too or stub it).
- Step 6: Commit
git add app/src/components/connector/
git commit -m "feat: add connector wallet components (ConnectButton, WalletModal)"
Task 11: App UI Primitives
Files: app/src/components/ui/Button.tsx, Badge.tsx, FieldRow.tsx, Toast.tsx
These use inline styles + CSS variables. No Tailwind.
- Step 1: Create
app/src/components/ui/Button.tsx
import type { CSSProperties, ReactNode, ButtonHTMLAttributes } from 'react'
export type ButtonVariant = 'primary' | 'outline' | 'danger' | 'ghost'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
children: ReactNode
}
const variantStyles: Record<ButtonVariant, CSSProperties> = {
primary: {
background: 'linear-gradient(135deg, var(--acc), var(--acc2))',
color: '#0b0613',
border: 'none',
},
outline: {
background: 'transparent',
border: '1px solid var(--bd)',
color: 'var(--tx)',
},
danger: {
background: 'transparent',
border: '1px solid var(--danger)',
color: 'var(--danger)',
},
ghost: {
background: 'var(--bg3)',
border: '1px solid var(--bd)',
color: 'var(--tx)',
},
}
export function Button({ variant = 'primary', children, style, disabled, ...rest }: ButtonProps) {
return (
<button
{...rest}
disabled={disabled}
style={{
padding: '11px 18px',
borderRadius: 'var(--radius)',
fontWeight: 700,
fontSize: 13,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
...variantStyles[variant],
...style,
}}
>
{children}
</button>
)
}
- Step 2: Create
app/src/components/ui/Badge.tsx
import type { ReactNode } from 'react'
interface BadgeProps {
children: ReactNode
color: string
bg: string
}
export function Badge({ children, color, bg }: BadgeProps) {
return (
<span
style={{
display: 'inline-block',
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
color,
background: bg,
}}
>
{children}
</span>
)
}
export const STATUS_COLORS = {
active: { color: '#14F195', bg: 'rgba(20,241,149,.15)' },
inactive: { color: '#8A8FA3', bg: 'rgba(138,143,163,.16)' },
awaitingConfirm: { color: '#F5B23E', bg: 'rgba(245,178,62,.15)' },
disputed: { color: '#FF7A59', bg: 'rgba(255,122,89,.15)' },
complete: { color: '#4FD1E0', bg: 'rgba(79,209,224,.15)' },
cancelled: { color: '#8A8FA3', bg: 'rgba(138,143,163,.16)' },
}
- Step 3: Create
app/src/components/ui/FieldRow.tsx
import type { ReactNode } from 'react'
interface FieldRowProps {
label: string
children: ReactNode
last?: boolean
}
export function FieldRow({ label, children, last }: FieldRowProps) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 18,
padding: '11px 0',
borderBottom: last ? 'none' : '1px solid var(--bdSoft)',
alignItems: 'flex-start',
}}
>
<div style={{ fontSize: 12.5, color: 'var(--mut)', fontWeight: 500, flexShrink: 0, paddingTop: 2 }}>
{label}
</div>
<div style={{ textAlign: 'right', minWidth: 0 }}>{children}</div>
</div>
)
}
export function MonoChip({
value,
onCopy,
onClick,
sub,
}: {
value: string
onCopy?: () => void
onClick?: () => void
sub?: string
}) {
function copy() {
navigator.clipboard.writeText(value)
onCopy?.()
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, justifyContent: 'flex-end' }}>
<span
onClick={onClick}
style={{
fontFamily: 'var(--font-mono)',
fontSize: 12.5,
color: onClick ? 'var(--acc2light)' : 'var(--tx)',
cursor: onClick ? 'pointer' : 'default',
}}
>
{value}
</span>
<button
onClick={copy}
style={{
fontSize: 9.5,
color: 'var(--mut)',
padding: '3px 6px',
borderRadius: 5,
background: 'var(--bg3)',
fontWeight: 600,
}}
>
COPY
</button>
</div>
{sub && <div style={{ fontSize: 11.5, color: 'var(--mut)', marginTop: 3 }}>{sub}</div>}
</div>
)
}
- Step 4: Create
app/src/components/ui/Toast.tsx
'use client'
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
const ToastCtx = createContext<(msg: string) => void>(() => {})
export function useToast() {
return useContext(ToastCtx)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [msg, setMsg] = useState('')
const show = useCallback((m: string) => {
setMsg(m)
setTimeout(() => setMsg(''), 2800)
}, [])
return (
<ToastCtx.Provider value={show}>
{children}
{msg && (
<div
style={{
position: 'fixed',
left: '50%',
bottom: 30,
transform: 'translateX(-50%)',
zIndex: 90,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 18px',
background: 'var(--bg2)',
border: '1px solid var(--accBd)',
borderRadius: 12,
boxShadow: '0 16px 40px rgba(0,0,0,.55)',
fontSize: 13,
fontWeight: 500,
animation: 'scToast .22s ease',
whiteSpace: 'nowrap',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--acc2)',
boxShadow: '0 0 8px var(--acc2)',
flexShrink: 0,
}}
/>
{msg}
</div>
)}
</ToastCtx.Provider>
)
}
- Step 5: Type-check
cd app && yarn typecheck
- Step 6: Commit
git add app/src/components/ui/
git commit -m "feat: add app UI primitives (Button, Badge, FieldRow, Toast)"
Task 12: Nav Component
Files: app/src/components/Nav.tsx
- Step 1: Create
app/src/components/Nav.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useRef, useEffect } from 'react'
import { useCluster } from '@solana/connector/react'
import { ConnectButton } from '@/components/connector/connect-button'
const NAV_LINKS = [
{ href: '/listings', label: 'Listings' },
{ href: '/escrows', label: 'Escrows' },
{ href: '/resolvers', label: 'Resolvers' },
{ href: '/dashboard', label: 'Dashboard' },
]
const NET_DOTS: Record<string, string> = {
'solana:localnet': '#FF7A59',
'solana:devnet': '#14F195',
'solana:mainnet': '#9945FF',
}
export function Nav() {
const pathname = usePathname()
const router = useRouter()
const { cluster, clusters, setCluster } = useCluster()
const [netOpen, setNetOpen] = useState(false)
const [search, setSearch] = useState('')
const netRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function close(e: MouseEvent) {
if (netRef.current && !netRef.current.contains(e.target as Node)) setNetOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [])
function handleSearch(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && search.trim()) {
router.push(`/search?q=${encodeURIComponent(search.trim())}`)
setSearch('')
}
}
const dot = NET_DOTS[cluster?.id ?? ''] ?? '#8A8FA3'
return (
<nav
style={{
position: 'sticky',
top: 0,
zIndex: 40,
display: 'flex',
alignItems: 'center',
gap: 18,
height: 62,
padding: '0 22px',
background: 'var(--navbg)',
backdropFilter: 'blur(14px)',
borderBottom: '1px solid var(--bd)',
}}
>
{/* Logo */}
<div
onClick={() => router.push('/listings')}
style={{ display: 'flex', alignItems: 'center', gap: 11, cursor: 'pointer', flexShrink: 0 }}
>
<div
style={{
width: 30,
height: 30,
borderRadius: 9,
background: 'linear-gradient(135deg, var(--acc), var(--acc2))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 18px var(--accGlow)',
}}
>
<div
style={{ width: 11, height: 11, background: 'var(--bg)', transform: 'rotate(45deg)', borderRadius: 2 }}
/>
</div>
<div style={{ lineHeight: 1.05 }}>
<div style={{ fontWeight: 700, fontSize: 16, letterSpacing: '-.01em' }}>Solisting</div>
<div style={{ fontSize: 9, fontWeight: 600, letterSpacing: '.22em', color: 'var(--mut)' }}>
EXPLORER
</div>
</div>
</div>
{/* Search */}
<div style={{ position: 'relative', flex: 1, maxWidth: 440 }}>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearch}
placeholder="Search address · listing · order · escrow · resolver"
style={{
width: '100%',
height: 38,
padding: '0 14px 0 36px',
background: 'var(--bg3)',
border: '1px solid var(--bd)',
borderRadius: 10,
fontSize: 13,
color: 'var(--tx)',
outline: 'none',
}}
/>
<div
style={{
position: 'absolute',
left: 12,
top: '50%',
transform: 'translateY(-50%)',
width: 13,
height: 13,
border: '1.6px solid var(--mut)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>
</div>
{/* Nav links */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{NAV_LINKS.map((link) => {
const active = pathname.startsWith(link.href)
return (
<button
key={link.href}
onClick={() => router.push(link.href)}
style={{
padding: '8px 13px',
borderRadius: 9,
fontSize: 13,
fontWeight: active ? 600 : 400,
color: active ? 'var(--tx)' : 'var(--mut)',
background: active ? 'var(--bg3)' : 'transparent',
}}
>
{link.label}
</button>
)
})}
</div>
{/* Network picker */}
<div ref={netRef} style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setNetOpen((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
height: 38,
padding: '0 12px',
borderRadius: 10,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
fontSize: 13,
fontWeight: 500,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: dot, boxShadow: `0 0 8px ${dot}` }} />
{cluster?.label ?? 'Network'}
<span style={{ color: 'var(--mut)', fontSize: 10 }}>▾</span>
</button>
{netOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: 46,
width: 188,
background: 'var(--bg2)',
border: '1px solid var(--bd)',
borderRadius: 12,
padding: 6,
boxShadow: '0 18px 44px rgba(0,0,0,.5)',
zIndex: 50,
}}
>
{clusters.map((c) => {
const d = NET_DOTS[c.id] ?? '#8A8FA3'
const selected = c.id === cluster?.id
return (
<button
key={c.id}
onClick={() => { setCluster(c.id as never); setNetOpen(false) }}
style={{
display: 'flex',
alignItems: 'center',
gap: 9,
width: '100%',
padding: '9px 10px',
borderRadius: 8,
fontSize: 13,
textAlign: 'left',
background: selected ? 'var(--bg3)' : 'transparent',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: d }} />
<span style={{ flex: 1 }}>{c.label}</span>
{selected && <span style={{ color: 'var(--acc2)', fontSize: 12 }}>✓</span>}
</button>
)
})}
</div>
)}
</div>
{/* Wallet */}
<div style={{ flexShrink: 0 }}>
<ConnectButton />
</div>
</nav>
)
}
- Step 2: Type-check
cd app && yarn typecheck
- Step 3: Commit
git add app/src/components/Nav.tsx
git commit -m "feat: add Nav component"
Task 13: Root Layout + Redirect Page
Files: app/src/app/layout.tsx, app/src/app/page.tsx
- Step 1: Create
app/src/app/layout.tsx
import type { ReactNode } from 'react'
import { WalletProviders } from '@/providers/WalletProviders'
import { ToastProvider } from '@/components/ui/Toast'
import { Nav } from '@/components/Nav'
import '@/styles/globals.css'
export const metadata = {
title: 'Solisting Explorer',
description: 'Explorer for the Solisting protocol on Solana',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<WalletProviders>
<ToastProvider>
<Nav />
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 22px 90px' }}>
{children}
</main>
</ToastProvider>
</WalletProviders>
</body>
</html>
)
}
- Step 2: Create
app/src/app/page.tsx
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/listings')
}
- Step 3: Start dev server and verify the app renders without crashing
cd app && yarn dev
Open http://localhost:3000 in a browser. Expected: redirects to /listings (which will 404 for now), Nav renders with logo, search box, network picker, connect button. No console errors.
- Step 4: Commit
git add app/src/app/
git commit -m "feat: add root layout and redirect page"
Next: Proceed to Part 3 — 2026-06-22-solisting-app-read-views.md