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

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.tsx and 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 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:

// 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