1157 lines
32 KiB
Markdown
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`
|