Files
solisting/docs/superpowers/plans/2026-06-22-solisting-app-foundation.md

1157 lines
32 KiB
Markdown

# 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`**
```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`**
```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`**
```ts
import type { NextConfig } from 'next'
const config: NextConfig = {
reactStrictMode: true,
}
export default config
```
- [ ] **Step 4: Create `app/postcss.config.mjs`**
```js
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
```
- [ ] **Step 5: Install dependencies**
```bash
yarn install
```
Expected: `app/node_modules/` populated with all deps including `@solisting/sdk` resolved from the local workspace.
- [ ] **Step 6: Commit**
```bash
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`**
```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`**
```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`**
```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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
cd app && yarn typecheck
```
- [ ] **Step 3: Commit**
```bash
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`**
```bash
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`**
```bash
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`**
```bash
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`**
```bash
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:
```bash
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**
```bash
cd app && yarn typecheck
```
Fix any import errors by checking if additional dependencies are missing from `package.json`.
- [ ] **Step 7: Commit**
```bash
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`**
```bash
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.tsx` and strip WalletConnect**
```bash
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 `walletConnectUri` and `onClearWalletConnectUri` from the props interface and destructuring
- Remove the entire QR code section (the `{walletConnectUri && ...}` block)
- Remove the `walletConnectUri?: string | null` and `onClearWalletConnectUri?: () => void` from `WalletModalProps`
- [ ] **Step 3: Update `connect-button.tsx` to remove WalletConnect props**
In `connect-button.tsx`, the `WalletModal` is called with `walletConnectUri` and `onClearWalletConnectUri` props. Remove these:
```tsx
// 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:
```tsx
'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**
```bash
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**
```bash
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`**
```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`**
```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`**
```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`**
```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**
```bash
cd app && yarn typecheck
```
- [ ] **Step 6: Commit**
```bash
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`**
```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**
```bash
cd app && yarn typecheck
```
- [ ] **Step 3: Commit**
```bash
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`**
```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`**
```tsx
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/listings')
}
```
- [ ] **Step 3: Start dev server and verify the app renders without crashing**
```bash
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**
```bash
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`