39246-vm/docs/superpowers/plans/2026-03-11-frontend-migration.txt
abbashkyt-creator 7d8ce0e322 V0.1
2026-03-14 04:02:22 +03:00

2648 lines
85 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Ghost Node Frontend Migration Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace dashboard.html with a Next.js + React + TypeScript + Tailwind frontend, tab-by-tab, while the Python backend runs untouched on port 8000.
**Architecture:** Next.js App Router frontend in `frontend/` alongside existing files. All API calls proxy to FastAPI on port 8000 via Next.js rewrites. Built phase-by-phase — old dashboard.html stays live until Phase 7.
**Tech Stack:** Next.js 14, React 18, TypeScript, Tailwind CSS, shadcn/ui, Zustand, TanStack Query, Framer Motion, @dnd-kit, Lucide React, next/image, next/font, Vitest + React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-11-frontend-migration-design.md`
---
## Chunk 1: Phase 0 — Scaffold
### Task 1: Prerequisites + Project Init
**Files:**
- Create: `frontend/package.json` (via npx)
- Create: `frontend/next.config.ts`
- Create: `frontend/tsconfig.json` (via npx)
- [ ] **Step 1: Verify Node.js is installed**
```bash
node --version
npm --version
```
Expected: Node 18+ and npm 9+. If not installed, download from nodejs.org.
- [ ] **Step 2: Create Next.js app**
```bash
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --no-src-dir --import-alias "@/*"
```
When prompted, accept all defaults.
- [ ] **Step 3: Install all additional dependencies**
```bash
cd frontend
npm install zustand @tanstack/react-query framer-motion @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities lucide-react
npm install class-variance-authority clsx tailwind-merge
npm install @radix-ui/react-dialog @radix-ui/react-toast @radix-ui/react-slot
```
- [ ] **Step 4: Install dev/test dependencies**
```bash
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
```
- [ ] **Step 5: Add vitest config to package.json**
Open `frontend/package.json` and add to the `"scripts"` section:
```json
"test": "vitest",
"test:ui": "vitest --ui"
```
And add after `"devDependencies"`:
```json
,
"vitest": {
"environment": "jsdom",
"globals": true,
"setupFiles": ["./vitest.setup.ts"]
}
```
- [ ] **Step 6: Create vitest setup file**
Create `frontend/vitest.setup.ts`:
```ts
import '@testing-library/jest-dom'
```
- [ ] **Step 7: Commit**
```bash
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
git add frontend/
git commit -m "feat: scaffold Next.js frontend project"
```
---
### Task 2: Configure next.config.ts + Tailwind
**Files:**
- Modify: `frontend/next.config.ts`
- Modify: `frontend/tailwind.config.ts`
- Modify: `frontend/app/globals.css`
- [ ] **Step 1: Write failing test for theme colors**
Create `frontend/__tests__/theme.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from '../tailwind.config'
const config = resolveConfig(tailwindConfig as any)
describe('Ghost Node theme', () => {
it('has ghost accent color defined', () => {
expect((config.theme.colors as any).ghost?.accent).toBe('#00ff88')
})
it('has ghost bg color defined', () => {
expect((config.theme.colors as any).ghost?.bg).toBe('#0a0e17')
})
})
```
- [ ] **Step 2: Run test — expect FAIL**
```bash
cd frontend && npm test -- --run theme
```
- [ ] **Step 3: Replace frontend/next.config.ts**
```ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async rewrites() {
return [
{ source: '/api/:path*', destination: 'http://localhost:8000/api/:path*' },
]
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**' },
{ protocol: 'http', hostname: '**' },
],
},
}
export default nextConfig
```
- [ ] **Step 4: Replace frontend/tailwind.config.ts**
```ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
ghost: {
bg: '#0a0e17',
panel: '#111827',
border: '#1f2937',
accent: '#00ff88',
gold: '#fbbf24',
danger: '#ef4444',
dim: '#6b7280',
text: '#e5e7eb',
},
},
fontFamily: {
sans: ['var(--font-inter)', 'sans-serif'],
mono: ['var(--font-jetbrains)', 'monospace'],
},
},
},
plugins: [],
}
export default config
```
- [ ] **Step 5: Replace frontend/app/globals.css**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Override shadcn/ui CSS variables to match Ghost Node cyberpunk palette */
:root {
--background: 10 14 23; /* ghost-bg */
--foreground: 229 231 235; /* ghost-text */
--card: 17 24 39; /* ghost-panel */
--card-foreground: 229 231 235;
--primary: 0 255 136; /* ghost-accent */
--primary-foreground: 10 14 23;
--destructive: 239 68 68; /* ghost-danger */
--border: 31 41 55; /* ghost-border */
--muted: 107 114 128; /* ghost-dim */
--radius: 0.25rem;
}
* { box-sizing: border-box; }
body {
background-color: #0a0e17;
color: #e5e7eb;
font-family: var(--font-inter), sans-serif;
}
/* Scrollbar styling — cyberpunk */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #111827; }
::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #00ff88; }
```
- [ ] **Step 6: Run test — expect PASS**
```bash
npm test -- --run theme
```
- [ ] **Step 7: Commit**
```bash
cd ..
git add frontend/next.config.ts frontend/tailwind.config.ts frontend/app/globals.css frontend/__tests__/theme.test.ts frontend/vitest.setup.ts
git commit -m "feat: configure Next.js proxy, cyberpunk Tailwind theme, shadcn overrides"
```
---
### Task 3: TypeScript Types
**Files:**
- Create: `frontend/lib/types.ts`
- Create: `frontend/__tests__/types.test.ts`
- [ ] **Step 1: Write type shape test**
Create `frontend/__tests__/types.test.ts`:
```ts
import { describe, it, expectTypeOf } from 'vitest'
import type { Listing, Keyword, TargetSite, Stats, Config, SiteSelectors } from '../lib/types'
describe('Types', () => {
it('Listing has parsed images as string array', () => {
expectTypeOf<Listing['images']>().toEqualTypeOf<string[]>()
})
it('Listing has closing_alerts_sent as number array', () => {
expectTypeOf<Listing['closing_alerts_sent']>().toEqualTypeOf<number[]>()
})
it('TargetSite enabled is 0|1 not boolean', () => {
expectTypeOf<TargetSite['enabled']>().toEqualTypeOf<0 | 1>()
})
it('Stats engine_status is string union', () => {
expectTypeOf<Stats['engine_status']>().toEqualTypeOf<'Idle' | 'Running' | 'Paused'>()
})
})
```
- [ ] **Step 2: Run — expect type errors (types don't exist yet)**
```bash
cd frontend && npm test -- --run types
```
- [ ] **Step 3: Create frontend/lib/types.ts**
```ts
export interface Listing {
id: number
title: string
price: number | null
currency: string
price_raw: string
price_usd: number | null
time_left: string
time_left_mins: number | null
link: string
score: number
keyword: string
site_name: string
timestamp: string
price_updated_at: string | null
ai_match: 1 | 0 | null
ai_reason: string | null
location: string | null
images: string[] // parsed from JSON string at API layer
closing_alerts_sent: number[] // parsed from JSON string at API layer
}
export interface Keyword {
id: number
term: string
weight: number
ai_target: string | null
min_price: number | null
max_price: number | null
sort_order: number
}
export interface TargetSite {
id: number
name: string
url_template: string
search_selector: string
enabled: 0 | 1
max_pages: number
last_error: string | null
error_count: number
consecutive_failures: number
last_success_at: string | null
cooldown_until: string | null
requires_login: 0 | 1
login_url: string | null
login_check_selector: string | null
login_enabled: 0 | 1
sort_order: number
}
export interface SiteSelectors {
site_id: number
confidence: number
container_sel: string | null
title_sel: string | null
price_sel: string | null
time_sel: string | null
link_sel: string | null
next_page_sel: string | null
stale: boolean
provider: 'groq' | 'ollama' | null
generated_at: string | null
}
export interface Stats {
total_scanned: number
total_alerts: number
last_cycle: string
engine_status: 'Idle' | 'Running' | 'Paused'
uptime_start: number
uptime_seconds: number
}
export interface Config {
key: string
value: string
}
```
- [ ] **Step 4: Run — expect PASS**
```bash
npm test -- --run types
```
- [ ] **Step 5: Commit**
```bash
cd ..
git add frontend/lib/types.ts frontend/__tests__/types.test.ts
git commit -m "feat: add TypeScript types for all Ghost Node entities"
```
---
### Task 4: Engine Store + SSE Hook
**Files:**
- Create: `frontend/store/engineStore.ts`
- Create: `frontend/hooks/useSSE.ts`
- Create: `frontend/app/api/stream/route.ts`
- Create: `frontend/__tests__/engineStore.test.ts`
- [ ] **Step 1: Write failing store test**
Create `frontend/__tests__/engineStore.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useEngineStore } from '../store/engineStore'
describe('engineStore', () => {
beforeEach(() => {
useEngineStore.setState({
status: 'Idle',
uptime_seconds: 0,
total_scanned: 0,
total_alerts: 0,
last_cycle: 'Never',
isOffline: false,
})
})
it('initial state is Idle', () => {
expect(useEngineStore.getState().status).toBe('Idle')
})
it('setStats updates all fields', () => {
useEngineStore.getState().setStats({
engine_status: 'Running',
uptime_seconds: 120,
total_scanned: 42,
total_alerts: 3,
last_cycle: '2026-03-11T10:00:00',
uptime_start: Date.now() / 1000,
})
const s = useEngineStore.getState()
expect(s.status).toBe('Running')
expect(s.total_scanned).toBe(42)
})
it('setOffline marks isOffline true', () => {
useEngineStore.getState().setOffline(true)
expect(useEngineStore.getState().isOffline).toBe(true)
})
})
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd frontend && npm test -- --run engineStore
```
- [ ] **Step 3: Create frontend/store/engineStore.ts**
```ts
import { create } from 'zustand'
import type { Stats } from '@/lib/types'
interface EngineState {
status: 'Idle' | 'Running' | 'Paused'
uptime_seconds: number
total_scanned: number
total_alerts: number
last_cycle: string
isOffline: boolean
setStats: (stats: Stats) => void
setOffline: (offline: boolean) => void
}
export const useEngineStore = create<EngineState>((set) => ({
status: 'Idle',
uptime_seconds: 0,
total_scanned: 0,
total_alerts: 0,
last_cycle: 'Never',
isOffline: false,
setStats: (stats) => set({
status: stats.engine_status,
uptime_seconds: stats.uptime_seconds,
total_scanned: stats.total_scanned,
total_alerts: stats.total_alerts,
last_cycle: stats.last_cycle,
isOffline: false,
}),
setOffline: (offline) => set({ isOffline: offline }),
}))
```
- [ ] **Step 4: Run — expect PASS**
```bash
npm test -- --run engineStore
```
- [ ] **Step 5: Create frontend/app/api/stream/route.ts**
```ts
export const dynamic = 'force-dynamic'
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
// Poll FastAPI stats every 3s and push to browser
while (true) {
try {
const res = await fetch('http://localhost:8000/api/stats', {
signal: AbortSignal.timeout(2000),
})
if (res.ok) {
const stats = await res.json()
send({ type: 'stats', payload: stats })
}
} catch {
send({ type: 'offline' })
}
await new Promise((r) => setTimeout(r, 3000))
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
```
- [ ] **Step 6: Create frontend/hooks/useSSE.ts**
```ts
'use client'
import { useEffect } from 'react'
import { useEngineStore } from '@/store/engineStore'
export function useSSE() {
const setStats = useEngineStore((s) => s.setStats)
const setOffline = useEngineStore((s) => s.setOffline)
useEffect(() => {
const es = new EventSource('/api/stream')
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
if (msg.type === 'stats') setStats(msg.payload)
if (msg.type === 'offline') setOffline(true)
} catch {}
}
es.onerror = () => setOffline(true)
return () => es.close()
}, [setStats, setOffline])
}
```
- [ ] **Step 7: Commit**
```bash
cd ..
git add frontend/store/engineStore.ts frontend/hooks/useSSE.ts frontend/app/api/stream/route.ts frontend/__tests__/engineStore.test.ts
git commit -m "feat: engine Zustand store + SSE API route + useSSE hook"
```
---
### Task 5: Layout Shell (Header, Nav, StatusBar, Providers)
**Files:**
- Create: `frontend/app/providers.tsx`
- Create: `frontend/app/layout.tsx`
- Create: `frontend/app/page.tsx`
- Create: `frontend/components/layout/Header.tsx`
- Create: `frontend/components/layout/Nav.tsx`
- Create: `frontend/components/layout/StatusBar.tsx`
- Create: `frontend/lib/utils.ts`
- Create: `frontend/middleware.ts`
- [ ] **Step 1: Write StatusBar render test**
Create `frontend/__tests__/StatusBar.test.tsx`:
```tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { useEngineStore } from '../store/engineStore'
import StatusBar from '../components/layout/StatusBar'
// useSSE does nothing in tests
vi.mock('../hooks/useSSE', () => ({ useSSE: () => {} }))
describe('StatusBar', () => {
beforeEach(() => {
useEngineStore.setState({
status: 'Running',
uptime_seconds: 3661,
total_scanned: 99,
total_alerts: 5,
last_cycle: 'Never',
isOffline: false,
setStats: vi.fn(),
setOffline: vi.fn(),
})
})
it('shows engine status', () => {
render(<StatusBar />)
expect(screen.getByText(/RUNNING/i)).toBeTruthy()
})
it('formats uptime correctly', () => {
render(<StatusBar />)
expect(screen.getByText(/1h 1m/i)).toBeTruthy()
})
it('shows offline banner when isOffline', () => {
useEngineStore.setState({ isOffline: true } as any)
render(<StatusBar />)
expect(screen.getByText(/OFFLINE/i)).toBeTruthy()
})
})
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd frontend && npm test -- --run StatusBar
```
- [ ] **Step 3: Create frontend/lib/utils.ts**
```ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
```
- [ ] **Step 4: Create frontend/components/layout/StatusBar.tsx**
```tsx
'use client'
import { useEngineStore } from '@/store/engineStore'
import { useSSE } from '@/hooks/useSSE'
import { formatUptime } from '@/lib/utils'
export default function StatusBar() {
useSSE() // connects SSE on mount
const { status, uptime_seconds, total_scanned, total_alerts, isOffline } =
useEngineStore()
if (isOffline) {
return (
<div className="bg-ghost-danger/20 border-b border-ghost-danger px-4 py-1 text-center text-xs font-mono text-ghost-danger">
ENGINE OFFLINE — cannot reach localhost:8000
</div>
)
}
const statusColor =
status === 'Running' ? 'text-ghost-accent' :
status === 'Paused' ? 'text-ghost-gold' : 'text-ghost-dim'
return (
<div className="border-b border-ghost-border bg-ghost-panel px-4 py-1 flex gap-6 text-xs font-mono">
<span>
ENGINE: <span className={statusColor}>{status.toUpperCase()}</span>
</span>
<span className="text-ghost-dim">UPTIME: <span className="text-ghost-text">{formatUptime(uptime_seconds)}</span></span>
<span className="text-ghost-dim">SCANNED: <span className="text-ghost-text">{total_scanned}</span></span>
<span className="text-ghost-dim">ALERTS: <span className="text-ghost-gold">{total_alerts}</span></span>
</div>
)
}
```
- [ ] **Step 5: Run StatusBar test — expect PASS**
```bash
npm test -- --run StatusBar
```
- [ ] **Step 6: Create frontend/components/layout/Nav.tsx**
```tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
const TABS = [
{ href: '/dashboard', label: 'DASHBOARD', icon: '📡' },
{ href: '/listings', label: 'LISTINGS', icon: '🎯' },
{ href: '/keywords', label: 'KEYWORDS', icon: '🔍' },
{ href: '/sites', label: 'TARGET SITES', icon: '🌐' },
{ href: '/settings', label: 'SETTINGS', icon: '⚙️' },
{ href: '/ai-log', label: 'AI LOG', icon: '🧠' },
]
export default function Nav() {
const pathname = usePathname()
return (
<nav className="flex border-b border-ghost-border bg-ghost-panel">
{TABS.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={cn(
'px-4 py-2 text-xs font-mono tracking-wider transition-colors border-b-2',
pathname.startsWith(tab.href)
? 'border-ghost-accent text-ghost-accent'
: 'border-transparent text-ghost-dim hover:text-ghost-text'
)}
>
{tab.icon} {tab.label}
</Link>
))}
</nav>
)
}
```
- [ ] **Step 7: Create frontend/components/layout/Header.tsx**
```tsx
'use client'
import { useEngineStore } from '@/store/engineStore'
const API = 'http://localhost:8000'
export default function Header() {
const status = useEngineStore((s) => s.status)
const call = (path: string) => fetch(`${API}/api/engine/${path}`, { method: 'POST' })
return (
<header className="flex items-center justify-between px-4 py-2 bg-ghost-bg border-b border-ghost-border">
<div className="flex items-center gap-3">
<span className="text-2xl">👻</span>
<div>
<div className="font-mono text-ghost-accent font-bold tracking-widest text-sm">
GHOST NODE
</div>
<div className="font-mono text-ghost-dim text-xs">AUCTION SNIPER v2.5</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => call('pause')}
className="px-3 py-1 text-xs font-mono border border-ghost-border text-ghost-dim hover:text-ghost-gold hover:border-ghost-gold transition-colors"
>
⏸ PAUSE
</button>
<button
onClick={() => call('resume')}
className="px-3 py-1 text-xs font-mono border border-ghost-border text-ghost-dim hover:text-ghost-accent hover:border-ghost-accent transition-colors"
>
▶ RESUME
</button>
<button
onClick={() => call('restart')}
className="px-3 py-1 text-xs font-mono border border-ghost-border text-ghost-dim hover:text-ghost-text hover:border-ghost-text transition-colors"
>
🔄 RESTART
</button>
<button
onClick={() => { if (confirm('Kill engine?')) call('kill') }}
className="px-3 py-1 text-xs font-mono border border-ghost-danger text-ghost-danger hover:bg-ghost-danger hover:text-ghost-bg transition-colors"
>
☠ KILL
</button>
</div>
</header>
)
}
```
- [ ] **Step 8: Create frontend/app/providers.tsx**
```tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export default function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 5000, retry: 1 } },
}))
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
```
- [ ] **Step 9: Replace frontend/app/layout.tsx**
```tsx
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import './globals.css'
import Providers from './providers'
import Header from '@/components/layout/Header'
import Nav from '@/components/layout/Nav'
import StatusBar from '@/components/layout/StatusBar'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-jetbrains' })
export const metadata: Metadata = {
title: 'Ghost Node — Auction Sniper',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="bg-ghost-bg text-ghost-text min-h-screen">
<Providers>
<Header />
<StatusBar />
<Nav />
<main className="p-4">{children}</main>
</Providers>
</body>
</html>
)
}
```
- [ ] **Step 10: Create frontend/app/page.tsx**
```tsx
import { redirect } from 'next/navigation'
export default function Home() { redirect('/dashboard') }
```
- [ ] **Step 11: Create frontend/middleware.ts (no-op)**
```ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// No-op auth guard — NextAuth.js drops in here when SaaS multi-user launches
export function middleware(request: NextRequest) {
return NextResponse.next()
}
export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }
```
- [ ] **Step 12: Run all tests**
```bash
cd frontend && npm test -- --run
```
Expected: All tests pass.
- [ ] **Step 13: Start dev server and verify**
```bash
npm run dev
```
Open browser at `http://localhost:3000`. Expected: Ghost Node header, nav bar, StatusBar showing engine state. Clicking tabs shows placeholder pages. No console errors.
- [ ] **Step 14: Commit**
```bash
cd ..
git add frontend/
git commit -m "feat: Phase 0 complete — layout shell, providers, SSE, middleware"
```
---
## Chunk 2: Phase 1 — Dashboard Tab
### Task 6: API layer (stats + engine) + Dashboard page
**Files:**
- Create: `frontend/lib/api/engine.ts`
- Create: `frontend/app/dashboard/page.tsx`
- Create: `frontend/components/dashboard/StatsGrid.tsx`
- Create: `frontend/components/dashboard/ActivityLog.tsx`
- Create: `frontend/__tests__/StatsGrid.test.tsx`
- [ ] **Step 1: Write StatsGrid test**
Create `frontend/__tests__/StatsGrid.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import StatsGrid from '../components/dashboard/StatsGrid'
describe('StatsGrid', () => {
it('renders all four stat cards', () => {
render(<StatsGrid scanned={42} alerts={3} uptime="1h 2m" status="Running" />)
expect(screen.getByText('42')).toBeTruthy()
expect(screen.getByText('3')).toBeTruthy()
expect(screen.getByText('1h 2m')).toBeTruthy()
expect(screen.getByText('Running')).toBeTruthy()
})
})
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd frontend && npm test -- --run StatsGrid
```
- [ ] **Step 3: Create frontend/components/dashboard/StatsGrid.tsx**
```tsx
interface Props {
scanned: number
alerts: number
uptime: string
status: string
}
const Card = ({ label, value, sub }: { label: string; value: string | number; sub: string }) => (
<div className="bg-ghost-panel border border-ghost-border p-4 font-mono">
<div className="text-ghost-dim text-xs tracking-wider mb-2">{label}</div>
<div className="text-ghost-gold text-3xl font-bold">{value}</div>
<div className="text-ghost-dim text-xs mt-1">{sub}</div>
</div>
)
export default function StatsGrid({ scanned, alerts, uptime, status }: Props) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Card label="// TOTAL SCANNED" value={scanned} sub="listings processed" />
<Card label="// ALERTS FIRED" value={alerts} sub="qualifying hits" />
<Card label="// UPTIME" value={uptime} sub="continuous operation" />
<Card label="// ENGINE" value={status} sub="current state" />
</div>
)
}
```
- [ ] **Step 4: Run — expect PASS**
```bash
npm test -- --run StatsGrid
```
- [ ] **Step 5: Create frontend/components/dashboard/ActivityLog.tsx**
```tsx
'use client'
import { useEffect, useRef, useState } from 'react'
interface LogEntry { id: number; time: string; msg: string }
export default function ActivityLog() {
const [entries, setEntries] = useState<LogEntry[]>([
{ id: 0, time: new Date().toLocaleTimeString(), msg: 'Ghost Node dashboard initialized.' },
])
const [filter, setFilter] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [entries])
const filtered = filter
? entries.filter((e) => e.msg.toLowerCase().includes(filter.toLowerCase()))
: entries
return (
<div className="bg-ghost-panel border border-ghost-border">
<div className="flex items-center gap-2 border-b border-ghost-border px-3 py-2">
<span className="text-ghost-accent font-mono text-xs">// ACTIVITY LOG</span>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="filter log..."
className="ml-auto bg-ghost-bg border border-ghost-border text-ghost-text text-xs font-mono px-2 py-1 outline-none focus:border-ghost-accent w-40"
/>
<button
onClick={() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' })}
className="text-ghost-dim hover:text-ghost-accent text-xs font-mono"
>↓</button>
<button
onClick={() => setEntries([])}
className="text-ghost-dim hover:text-ghost-danger text-xs font-mono"
>🗑</button>
</div>
<div className="h-48 overflow-y-auto p-3 font-mono text-xs space-y-1">
{filtered.map((e) => (
<div key={e.id} className="flex gap-2">
<span className="text-ghost-dim shrink-0">[{e.time}]</span>
<span className="text-ghost-text">{e.msg}</span>
</div>
))}
<div ref={bottomRef} />
</div>
</div>
)
}
```
- [ ] **Step 6: Create frontend/app/dashboard/page.tsx**
```tsx
'use client'
import { useEngineStore } from '@/store/engineStore'
import { formatUptime } from '@/lib/utils'
import StatsGrid from '@/components/dashboard/StatsGrid'
import ActivityLog from '@/components/dashboard/ActivityLog'
export default function DashboardPage() {
const { status, uptime_seconds, total_scanned, total_alerts } = useEngineStore()
return (
<div className="space-y-4">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">SYSTEM DASHBOARD</h1>
<p className="font-mono text-ghost-dim text-xs">REAL-TIME NODE TELEMETRY</p>
</div>
<StatsGrid
scanned={total_scanned}
alerts={total_alerts}
uptime={formatUptime(uptime_seconds)}
status={status}
/>
<ActivityLog />
</div>
)
}
```
- [ ] **Step 7: Run all tests**
```bash
npm test -- --run
```
- [ ] **Step 8: Verify in browser**
Ensure Ghost Node backend is running (`python worker.py`). Open `http://localhost:3000/dashboard`. Expected: 4 stat cards with live data from SSE, activity log with filter + scroll controls.
- [ ] **Step 9: Commit**
```bash
cd ..
git add frontend/
git commit -m "feat: Phase 1 complete — Dashboard tab with live stats and activity log"
```
---
## Chunk 3: Phase 2 — Listings Tab
### Task 7: Listings API + useListings + useCountdown
**Files:**
- Create: `frontend/lib/api/listings.ts`
- Create: `frontend/hooks/useListings.ts`
- Create: `frontend/hooks/useCountdown.ts`
- Create: `frontend/__tests__/listings-api.test.ts`
- Create: `frontend/__tests__/useCountdown.test.ts`
- [ ] **Step 1: Write listings API parse test**
Create `frontend/__tests__/listings-api.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { parseListingResponse } from '../lib/api/listings'
describe('parseListingResponse', () => {
it('parses images JSON string to array', () => {
const raw = { images: '["http://a.com/1.jpg","http://a.com/2.jpg"]', closing_alerts_sent: '[]' }
const result = parseListingResponse(raw as any)
expect(result.images).toEqual(['http://a.com/1.jpg', 'http://a.com/2.jpg'])
})
it('handles null images as empty array', () => {
const raw = { images: null, closing_alerts_sent: '[]' }
const result = parseListingResponse(raw as any)
expect(result.images).toEqual([])
})
it('parses closing_alerts_sent to number array', () => {
const raw = { images: '[]', closing_alerts_sent: '[60,30,10]' }
const result = parseListingResponse(raw as any)
expect(result.closing_alerts_sent).toEqual([60, 30, 10])
})
})
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd frontend && npm test -- --run listings-api
```
- [ ] **Step 3: Create frontend/lib/api/listings.ts**
```ts
import type { Listing } from '@/lib/types'
const BASE = 'http://localhost:8000'
type RawListing = Omit<Listing, 'images' | 'closing_alerts_sent'> & {
images: string | null
closing_alerts_sent: string | null
}
export function parseListingResponse(raw: RawListing): Listing {
return {
...raw,
images: raw.images ? JSON.parse(raw.images) : [],
closing_alerts_sent: raw.closing_alerts_sent ? JSON.parse(raw.closing_alerts_sent) : [],
}
}
export async function fetchListings(limit = 100): Promise<Listing[]> {
const res = await fetch(`${BASE}/api/listings?limit=${limit}`)
if (!res.ok) throw new Error('Failed to fetch listings')
const data: RawListing[] = await res.json()
return data.map(parseListingResponse)
}
export async function deleteListing(id: number): Promise<void> {
const res = await fetch(`${BASE}/api/listings/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete listing')
}
export async function deleteAllListings(): Promise<void> {
const res = await fetch(`${BASE}/api/listings`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to clear listings')
}
export async function fetchCountdownSync(): Promise<Array<{ id: number; time_left_mins: number }>> {
const res = await fetch(`${BASE}/api/listings/countdown-sync`)
if (!res.ok) throw new Error('Failed to sync countdown')
return res.json()
}
// Export endpoints — trigger as browser download, not fetch
export const getExportUrl = (format: 'csv' | 'json' | 'html') =>
`${BASE}/api/export/${format}`
```
- [ ] **Step 4: Run — expect PASS**
```bash
npm test -- --run listings-api
```
- [ ] **Step 5: Write countdown hook test**
Create `frontend/__tests__/useCountdown.test.ts`:
```ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCountdown } from '../hooks/useCountdown'
vi.mock('../lib/api/listings', () => ({
fetchCountdownSync: vi.fn().mockResolvedValue([
{ id: 1, time_left_mins: 30 },
{ id: 2, time_left_mins: 5 },
]),
}))
describe('useCountdown', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('returns null for unknown id before sync', async () => {
const { result } = renderHook(() => useCountdown())
expect(result.current(999)).toBeNull()
})
it('returns mins for known id after sync', async () => {
const { result } = renderHook(() => useCountdown())
await act(async () => { await vi.runAllTimersAsync() })
expect(result.current(1)).toBeCloseTo(30, 0)
})
})
```
- [ ] **Step 6: Run — expect FAIL**
```bash
npm test -- --run useCountdown
```
- [ ] **Step 7: Create frontend/hooks/useCountdown.ts**
```ts
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
import { fetchCountdownSync } from '@/lib/api/listings'
export function useCountdown() {
const offsets = useRef<Record<number, number>>({})
const syncedAt = useRef<number>(Date.now())
const [, forceRender] = useState(0)
const sync = useCallback(async () => {
try {
const data = await fetchCountdownSync()
data.forEach(({ id, time_left_mins }) => { offsets.current[id] = time_left_mins })
syncedAt.current = Date.now()
} catch {}
}, [])
useEffect(() => {
sync()
const syncInterval = setInterval(sync, 60_000)
const tickInterval = setInterval(() => forceRender((n) => n + 1), 1_000)
return () => { clearInterval(syncInterval); clearInterval(tickInterval) }
}, [sync])
return useCallback((id: number): number | null => {
if (!(id in offsets.current)) return null
const elapsed = (Date.now() - syncedAt.current) / 60_000
return Math.max(0, offsets.current[id] - elapsed)
}, [])
}
```
- [ ] **Step 8: Create frontend/hooks/useListings.ts**
```ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchListings, deleteListing, deleteAllListings } from '@/lib/api/listings'
export function useListings(limit = 100) {
return useQuery({
queryKey: ['listings', limit],
queryFn: () => fetchListings(limit),
refetchInterval: 10_000,
})
}
export function useDeleteListing() {
const qc = useQueryClient()
return useMutation({
mutationFn: deleteListing,
onSuccess: () => qc.invalidateQueries({ queryKey: ['listings'] }),
})
}
export function useDeleteAllListings() {
const qc = useQueryClient()
return useMutation({
mutationFn: deleteAllListings,
onSuccess: () => qc.invalidateQueries({ queryKey: ['listings'] }),
})
}
```
- [ ] **Step 9: Run all tests**
```bash
npm test -- --run
```
- [ ] **Step 10: Commit**
```bash
cd ..
git add frontend/
git commit -m "feat: listings API layer, useListings, useCountdown hooks"
```
---
### Task 8: Listings Table + Row + Detail Panel
**Files:**
- Create: `frontend/components/listings/ListingsTable.tsx`
- Create: `frontend/components/listings/ListingRow.tsx`
- Create: `frontend/components/listings/ListingDetailPanel.tsx`
- Create: `frontend/components/listings/ImageGallery.tsx`
- Create: `frontend/app/listings/page.tsx`
- Create: `frontend/__tests__/ListingRow.test.tsx`
- [ ] **Step 1: Write ListingRow test**
Create `frontend/__tests__/ListingRow.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import ListingRow from '../components/listings/ListingRow'
import type { Listing } from '../lib/types'
const mockListing: Listing = {
id: 1, title: 'RTX 4090 Gaming GPU', price: 299.99, currency: 'USD',
price_raw: '$299.99', price_usd: 299.99, time_left: '2h 30m',
time_left_mins: 150, link: 'https://example.com/lot/1', score: 30,
keyword: 'RTX 4090', site_name: 'eBay UK', timestamp: '2026-03-11T10:00:00',
price_updated_at: null, ai_match: 1, ai_reason: 'Matches RTX GPU target',
location: 'London, UK', images: ['https://example.com/img1.jpg'],
closing_alerts_sent: [],
}
vi.mock('../hooks/useCountdown', () => ({ useCountdown: () => () => 150 }))
describe('ListingRow', () => {
it('renders title', () => {
render(<table><tbody><ListingRow listing={mockListing} onSelect={vi.fn()} /></tbody></table>)
expect(screen.getByText(/RTX 4090 Gaming GPU/)).toBeTruthy()
})
it('shows AI match badge', () => {
render(<table><tbody><ListingRow listing={mockListing} onSelect={vi.fn()} /></tbody></table>)
expect(screen.getByTitle(/AI: match/i)).toBeTruthy()
})
it('shows score in gold', () => {
render(<table><tbody><ListingRow listing={mockListing} onSelect={vi.fn()} /></tbody></table>)
expect(screen.getByText('30')).toBeTruthy()
})
})
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd frontend && npm test -- --run ListingRow
```
- [ ] **Step 3: Create frontend/components/listings/ListingRow.tsx**
```tsx
'use client'
import Image from 'next/image'
import { motion } from 'framer-motion'
import { useCountdown } from '@/hooks/useCountdown'
import type { Listing } from '@/lib/types'
function formatMins(mins: number | null): string {
if (mins === null) return '—'
if (mins < 1) return '<1m'
const d = Math.floor(mins / 1440)
const h = Math.floor((mins % 1440) / 60)
const m = Math.floor(mins % 60)
return [d && `${d}d`, h && `${h}h`, `${m}m`].filter(Boolean).join(' ')
}
const AiBadge = ({ match }: { match: 1 | 0 | null }) => {
if (match === 1) return <span title="AI: match" className="text-ghost-accent text-xs">🤖✅</span>
if (match === 0) return <span title="AI: rejected" className="text-ghost-danger text-xs">🤖❌</span>
return <span className="text-ghost-dim text-xs">—</span>
}
interface Props { listing: Listing; onSelect: (l: Listing) => void }
export default function ListingRow({ listing, onSelect }: Props) {
const getTime = useCountdown()
const mins = getTime(listing.id) ?? listing.time_left_mins
const isUrgent = mins !== null && mins < 60
return (
<motion.tr
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`border-b border-ghost-border hover:bg-ghost-panel/50 transition-colors ${
listing.ai_match === 0 ? 'opacity-50' : ''
}`}
>
{/* Thumbnail */}
<td className="p-2 w-14">
{listing.images[0] ? (
<Image
src={listing.images[0]}
alt={listing.title}
width={48} height={48}
className="object-cover rounded"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
) : <div className="w-12 h-12 bg-ghost-border rounded" />}
</td>
{/* Title + location */}
<td className="p-2">
<button
onClick={() => onSelect(listing)}
className="text-left font-mono text-xs text-ghost-text hover:text-ghost-accent transition-colors block"
>
{listing.title.length > 60 ? listing.title.slice(0, 60) + '…' : listing.title}
</button>
{listing.location && (
<div className="text-ghost-dim text-xs">📍 {listing.location}</div>
)}
<div className="text-ghost-dim text-xs">{listing.site_name}</div>
</td>
{/* Price */}
<td className="p-2 font-mono text-xs text-ghost-gold whitespace-nowrap">
{listing.price_raw || '—'}
</td>
{/* Time left */}
<td className="p-2 font-mono text-xs whitespace-nowrap">
<span className={isUrgent ? 'text-ghost-danger animate-pulse' : 'text-ghost-text'}>
{formatMins(mins)}
</span>
</td>
{/* Score */}
<td className="p-2 font-mono text-xs text-ghost-gold text-center">{listing.score}</td>
{/* Keyword */}
<td className="p-2 font-mono text-xs text-ghost-accent">{listing.keyword}</td>
{/* AI badge */}
<td className="p-2 text-center"><AiBadge match={listing.ai_match} /></td>
</motion.tr>
)
}
```
- [ ] **Step 4: Run — expect PASS**
```bash
npm test -- --run ListingRow
```
- [ ] **Step 5: Create frontend/components/listings/ImageGallery.tsx**
```tsx
import Image from 'next/image'
export default function ImageGallery({ images }: { images: string[] }) {
if (!images.length) return null
return (
<div>
<div className="text-ghost-dim text-xs font-mono mb-2">LOT IMAGES</div>
<div className="flex flex-wrap gap-2">
{images.map((src, i) => (
<a key={i} href={src} target="_blank" rel="noopener noreferrer">
<Image
src={src}
alt={`Lot image ${i + 1}`}
width={140} height={110}
className="object-cover rounded border border-ghost-border hover:border-ghost-accent transition-colors"
onError={(e) => { e.currentTarget.parentElement!.style.display = 'none' }}
/>
</a>
))}
</div>
</div>
)
}
```
- [ ] **Step 6: Create frontend/components/listings/ListingDetailPanel.tsx**
```tsx
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import ImageGallery from './ImageGallery'
import type { Listing } from '@/lib/types'
interface Props { listing: Listing | null; onClose: () => void }
export default function ListingDetailPanel({ listing, onClose }: Props) {
return (
<AnimatePresence>
{listing && (
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed right-0 top-0 h-full w-96 bg-ghost-panel border-l border-ghost-border z-50 overflow-y-auto p-4"
>
<button onClick={onClose} className="text-ghost-dim hover:text-ghost-danger font-mono text-xs mb-4 block">
✕ CLOSE
</button>
<h2 className="font-mono text-ghost-accent text-xs mb-4 leading-relaxed">{listing.title}</h2>
<div className="space-y-2 font-mono text-xs mb-4">
<Row label="SITE" value={listing.site_name} />
<Row label="KEYWORD" value={listing.keyword} />
<Row label="PRICE" value={listing.price_raw || '—'} color="text-ghost-gold" />
<Row label="SCORE" value={String(listing.score)} color="text-ghost-gold" />
<Row label="LOCATION" value={listing.location || '—'} />
<Row label="AI" value={listing.ai_match === 1 ? `✅ ${listing.ai_reason}` : listing.ai_match === 0 ? `❌ ${listing.ai_reason}` : '—'} />
<Row label="CAPTURED" value={new Date(listing.timestamp).toLocaleString()} />
</div>
<ImageGallery images={listing.images} />
<a
href={listing.link} target="_blank" rel="noopener noreferrer"
className="mt-4 block text-center border border-ghost-accent text-ghost-accent font-mono text-xs py-2 hover:bg-ghost-accent hover:text-ghost-bg transition-colors"
>
OPEN LOT →
</a>
</motion.div>
)}
</AnimatePresence>
)
}
const Row = ({ label, value, color = 'text-ghost-text' }: { label: string; value: string; color?: string }) => (
<div className="flex gap-2">
<span className="text-ghost-dim w-20 shrink-0">{label}</span>
<span className={color}>{value}</span>
</div>
)
```
- [ ] **Step 7: Create frontend/components/listings/ListingsTable.tsx**
```tsx
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import ListingRow from './ListingRow'
import ListingDetailPanel from './ListingDetailPanel'
import { useListings, useDeleteAllListings } from '@/hooks/useListings'
import { getExportUrl } from '@/lib/api/listings'
import type { Listing } from '@/lib/types'
export default function ListingsTable() {
const { data: listings, isLoading, isError } = useListings()
const deleteAll = useDeleteAllListings()
const [selected, setSelected] = useState<Listing | null>(null)
const [search, setSearch] = useState('')
if (isLoading) return <SkeletonTable />
if (isError) return <ErrorBanner />
const filtered = search
? (listings ?? []).filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()) ||
l.keyword.toLowerCase().includes(search.toLowerCase())
)
: (listings ?? [])
return (
<>
<div className="flex gap-2 mb-3 items-center">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search listings..."
className="bg-ghost-bg border border-ghost-border text-ghost-text text-xs font-mono px-2 py-1 outline-none focus:border-ghost-accent"
/>
<span className="text-ghost-dim text-xs font-mono ml-auto">{filtered.length} lots</span>
<button onClick={() => window.open(getExportUrl('csv'))} className="btn-ghost">CSV</button>
<button onClick={() => window.open(getExportUrl('json'))} className="btn-ghost">JSON</button>
<button
onClick={() => { if (confirm('Clear all listings?')) deleteAll.mutate() }}
className="btn-danger"
>
CLEAR ALL
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-ghost-border text-ghost-dim text-left">
<th className="p-2 w-14"></th>
<th className="p-2">TITLE</th>
<th className="p-2">PRICE</th>
<th className="p-2">TIME LEFT</th>
<th className="p-2 text-center">SCORE</th>
<th className="p-2">KEYWORD</th>
<th className="p-2 text-center">AI</th>
</tr>
</thead>
<tbody>
{filtered.map((l, i) => (
<motion.tr
key={l.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.02 }}
style={{ display: 'contents' }}
>
<ListingRow listing={l} onSelect={setSelected} />
</motion.tr>
))}
</tbody>
</table>
{!filtered.length && (
<div className="text-ghost-dim text-xs font-mono text-center py-8">
NO LISTINGS CAPTURED YET
</div>
)}
</div>
<ListingDetailPanel listing={selected} onClose={() => setSelected(null)} />
</>
)
}
const SkeletonTable = () => (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 bg-ghost-panel border border-ghost-border animate-pulse" />
))}
</div>
)
const ErrorBanner = () => (
<div className="border border-ghost-danger bg-ghost-danger/10 text-ghost-danger font-mono text-xs p-4">
ENGINE OFFLINE — cannot reach localhost:8000
</div>
)
```
- [ ] **Step 8: Add utility classes to globals.css**
Append to `frontend/app/globals.css`:
```css
@layer components {
.btn-ghost {
@apply px-2 py-1 border border-ghost-border text-ghost-dim font-mono text-xs hover:border-ghost-accent hover:text-ghost-accent transition-colors;
}
.btn-danger {
@apply px-2 py-1 border border-ghost-danger text-ghost-danger font-mono text-xs hover:bg-ghost-danger hover:text-ghost-bg transition-colors;
}
}
```
- [ ] **Step 9: Create frontend/app/listings/page.tsx**
```tsx
import ListingsTable from '@/components/listings/ListingsTable'
export default function ListingsPage() {
return (
<div className="space-y-3">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">LISTINGS</h1>
<p className="font-mono text-ghost-dim text-xs">CAPTURED LOTS — LIVE</p>
</div>
<ListingsTable />
</div>
)
}
```
- [ ] **Step 10: Run all tests**
```bash
cd frontend && npm test -- --run
```
- [ ] **Step 11: Verify in browser**
Open `http://localhost:3000/listings`. Expected: table with real listings from the backend, thumbnails, countdown ticking, detail panel slides in on title click.
- [ ] **Step 12: Commit**
```bash
cd ..
git add frontend/
git commit -m "feat: Phase 2 complete — Listings tab with countdown, thumbnails, detail panel"
```
---
## Chunk 4: Phases 36 (Keywords, Sites, Settings, AI Log)
### Task 9: Keywords API + Tab
**Files:**
- Create: `frontend/lib/api/keywords.ts`
- Create: `frontend/lib/api/ai.ts`
- Create: `frontend/hooks/useKeywords.ts`
- Create: `frontend/components/keywords/KeywordsTable.tsx`
- Create: `frontend/components/keywords/KeywordRow.tsx`
- Create: `frontend/app/keywords/page.tsx`
- [ ] **Step 1: Create frontend/lib/api/keywords.ts**
```ts
import type { Keyword } from '@/lib/types'
const BASE = 'http://localhost:8000'
export const fetchKeywords = async (): Promise<Keyword[]> => {
const res = await fetch(`${BASE}/api/keywords`)
if (!res.ok) throw new Error('Failed to fetch keywords')
return res.json()
}
export const addKeyword = async (term: string, weight = 1): Promise<Keyword> => {
const res = await fetch(`${BASE}/api/keywords`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ term, weight }),
})
if (!res.ok) throw new Error('Failed to add keyword')
return res.json()
}
export const updateKeyword = async (id: number, data: Partial<Pick<Keyword, 'term' | 'weight' | 'ai_target' | 'min_price' | 'max_price'>>): Promise<void> => {
const res = await fetch(`${BASE}/api/keywords/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to update keyword')
}
export const deleteKeyword = async (id: number): Promise<void> => {
const res = await fetch(`${BASE}/api/keywords/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete keyword')
}
export const reorderKeywords = async (order: number[]): Promise<void> => {
const res = await fetch(`${BASE}/api/keywords/reorder`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
})
if (!res.ok) throw new Error('Failed to reorder')
}
```
- [ ] **Step 2: Create frontend/lib/api/ai.ts**
```ts
const BASE = 'http://localhost:8000'
export const testAI = async (title: string, ai_target: string) => {
const res = await fetch(`${BASE}/api/ai/test`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, ai_target }),
})
if (!res.ok) throw new Error('AI test failed')
return res.json() as Promise<{ verdict: string; reason: string }>
}
export const fetchAILog = async (limit = 50, since_id = 0) => {
const res = await fetch(`${BASE}/api/ai/debug/log?limit=${limit}&since_id=${since_id}`)
if (!res.ok) throw new Error('Failed to fetch AI log')
return res.json()
}
export const clearAILog = async () => {
await fetch(`${BASE}/api/ai/debug/log`, { method: 'DELETE' })
}
```
- [ ] **Step 3: Create frontend/hooks/useKeywords.ts**
```ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchKeywords, addKeyword, updateKeyword, deleteKeyword, reorderKeywords } from '@/lib/api/keywords'
const KEY = ['keywords']
export const useKeywords = () => useQuery({ queryKey: KEY, queryFn: fetchKeywords })
export const useAddKeyword = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: ({ term, weight }: { term: string; weight: number }) => addKeyword(term, weight), onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useUpdateKeyword = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: ({ id, data }: { id: number; data: Parameters<typeof updateKeyword>[1] }) => updateKeyword(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useDeleteKeyword = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: deleteKeyword, onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useReorderKeywords = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: reorderKeywords, onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
```
- [ ] **Step 4: Create frontend/components/keywords/KeywordRow.tsx**
```tsx
'use client'
import { useState, useRef } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useUpdateKeyword, useDeleteKeyword } from '@/hooks/useKeywords'
import type { Keyword } from '@/lib/types'
interface Props { keyword: Keyword }
export default function KeywordRow({ keyword }: Props) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: keyword.id })
const updateKw = useUpdateKeyword()
const deleteKw = useDeleteKeyword()
const [editTerm, setEditTerm] = useState(false)
const [editWeight, setEditWeight] = useState(false)
const [termVal, setTermVal] = useState(keyword.term)
const [weightVal, setWeightVal] = useState(String(keyword.weight))
const saveTerm = () => { updateKw.mutate({ id: keyword.id, data: { term: termVal } }); setEditTerm(false) }
const saveWeight = () => { updateKw.mutate({ id: keyword.id, data: { weight: parseFloat(weightVal) } }); setEditWeight(false) }
const style = { transform: CSS.Transform.toString(transform), transition }
return (
<tr ref={setNodeRef} style={style} className="border-b border-ghost-border hover:bg-ghost-panel/50">
<td className="p-2 w-6 cursor-grab text-ghost-dim" {...attributes} {...listeners}>⋮⋮</td>
<td className="p-2 font-mono text-xs">
{editTerm ? (
<input autoFocus value={termVal} onChange={(e) => setTermVal(e.target.value)}
onBlur={saveTerm} onKeyDown={(e) => e.key === 'Enter' && saveTerm()}
className="bg-ghost-bg border border-ghost-accent text-ghost-text font-mono text-xs px-1 outline-none w-full" />
) : (
<span onClick={() => setEditTerm(true)} className="cursor-pointer hover:text-ghost-accent text-ghost-text">{keyword.term}</span>
)}
</td>
<td className="p-2 font-mono text-xs text-center">
{editWeight ? (
<input autoFocus type="number" step="0.1" value={weightVal} onChange={(e) => setWeightVal(e.target.value)}
onBlur={saveWeight} onKeyDown={(e) => e.key === 'Enter' && saveWeight()}
className="bg-ghost-bg border border-ghost-accent text-ghost-gold font-mono text-xs px-1 outline-none w-16 text-center" />
) : (
<span onClick={() => setEditWeight(true)} className="cursor-pointer text-ghost-gold hover:text-ghost-accent">{keyword.weight}×</span>
)}
</td>
<td className="p-2 text-xs">
{keyword.ai_target && <span className="text-ghost-dim text-xs">🤖 {keyword.ai_target.slice(0, 30)}…</span>}
</td>
<td className="p-2 text-xs text-ghost-dim">
{(keyword.min_price || keyword.max_price) && (
<span>
{keyword.min_price ? `≥$${keyword.min_price}` : ''} {keyword.max_price ? `≤$${keyword.max_price}` : ''}
</span>
)}
</td>
<td className="p-2">
<button onClick={() => { if (confirm(`Delete "${keyword.term}"?`)) deleteKw.mutate(keyword.id) }}
className="text-ghost-danger hover:text-ghost-danger/80 font-mono text-xs">✕</button>
</td>
</tr>
)
}
```
- [ ] **Step 5: Create frontend/components/keywords/KeywordsTable.tsx**
```tsx
'use client'
import { useState } from 'react'
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import KeywordRow from './KeywordRow'
import { useKeywords, useAddKeyword, useReorderKeywords } from '@/hooks/useKeywords'
export default function KeywordsTable() {
const { data: keywords, isLoading } = useKeywords()
const addKw = useAddKeyword()
const reorder = useReorderKeywords()
const [newTerm, setNewTerm] = useState('')
const [newWeight, setNewWeight] = useState('1')
const [batchText, setBatchText] = useState('')
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || !keywords) return
const ids = keywords.map((k) => k.id)
const from = ids.indexOf(Number(active.id))
const to = ids.indexOf(Number(over.id))
const newOrder = [...ids]
newOrder.splice(to, 0, newOrder.splice(from, 1)[0])
reorder.mutate(newOrder)
}
const handleBatchImport = () => {
const lines = batchText.split('\n').map((l) => l.trim()).filter(Boolean)
lines.forEach((line) => {
const [term, weight] = line.split(':')
addKw.mutate({ term: term.trim(), weight: parseFloat(weight || '1') })
})
setBatchText('')
}
if (isLoading) return <div className="text-ghost-dim font-mono text-xs animate-pulse">Loading keywords…</div>
return (
<div className="space-y-4">
{/* Add keyword */}
<div className="flex gap-2">
<input value={newTerm} onChange={(e) => setNewTerm(e.target.value)}
placeholder="keyword term"
className="bg-ghost-bg border border-ghost-border text-ghost-text font-mono text-xs px-2 py-1 outline-none focus:border-ghost-accent flex-1" />
<input value={newWeight} onChange={(e) => setNewWeight(e.target.value)}
type="number" step="0.1" placeholder="weight"
className="bg-ghost-bg border border-ghost-border text-ghost-gold font-mono text-xs px-2 py-1 outline-none focus:border-ghost-accent w-20" />
<button onClick={() => { addKw.mutate({ term: newTerm, weight: parseFloat(newWeight) }); setNewTerm(''); setNewWeight('1') }}
className="btn-ghost">+ ADD</button>
</div>
{/* Keywords table with drag-drop */}
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={(keywords ?? []).map((k) => k.id)} strategy={verticalListSortingStrategy}>
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-ghost-border text-ghost-dim text-left">
<th className="p-2 w-6"></th>
<th className="p-2">KEYWORD</th>
<th className="p-2 text-center">WEIGHT</th>
<th className="p-2">AI TARGET</th>
<th className="p-2">PRICE FILTER</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{(keywords ?? []).map((kw) => <KeywordRow key={kw.id} keyword={kw} />)}
</tbody>
</table>
</SortableContext>
</DndContext>
{/* Batch import */}
<details className="border border-ghost-border">
<summary className="px-3 py-2 text-ghost-dim font-mono text-xs cursor-pointer hover:text-ghost-text">BATCH IMPORT</summary>
<div className="p-3 space-y-2">
<textarea value={batchText} onChange={(e) => setBatchText(e.target.value)}
placeholder={"laptop:2\nRTX 4090:3\niPhone 15"}
rows={4}
className="w-full bg-ghost-bg border border-ghost-border text-ghost-text font-mono text-xs p-2 outline-none focus:border-ghost-accent resize-none" />
<button onClick={handleBatchImport} className="btn-ghost">IMPORT</button>
</div>
</details>
</div>
)
}
```
- [ ] **Step 6: Create frontend/app/keywords/page.tsx**
```tsx
import KeywordsTable from '@/components/keywords/KeywordsTable'
export default function KeywordsPage() {
return (
<div className="space-y-3">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">KEYWORDS</h1>
<p className="font-mono text-ghost-dim text-xs">SEARCH TERMS + SCORING WEIGHTS</p>
</div>
<KeywordsTable />
</div>
)
}
```
- [ ] **Step 7: Verify in browser**
Open `http://localhost:3000/keywords`. Expected: keyword table with drag handles, inline edit on click, add keyword form, batch import accordion.
- [ ] **Step 8: Commit**
```bash
cd ..
git add frontend/
git commit -m "feat: Phase 3 complete — Keywords tab with drag-drop, inline edit, batch import"
```
---
### Task 10: Sites Tab
**Files:**
- Create: `frontend/lib/api/sites.ts`
- Create: `frontend/hooks/useSites.ts`
- Create: `frontend/components/sites/SiteRow.tsx`
- Create: `frontend/components/sites/SitesTable.tsx`
- Create: `frontend/app/sites/page.tsx`
- [ ] **Step 1: Create frontend/lib/api/sites.ts**
```ts
import type { TargetSite, SiteSelectors } from '@/lib/types'
const BASE = 'http://localhost:8000'
export const fetchSites = async (): Promise<TargetSite[]> => {
const res = await fetch(`${BASE}/api/sites`)
if (!res.ok) throw new Error('Failed to fetch sites')
return res.json()
}
export const addSite = async (data: Partial<TargetSite>): Promise<TargetSite> => {
const res = await fetch(`${BASE}/api/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (!res.ok) throw new Error('Failed to add site')
return res.json()
}
export const updateSite = async (id: number, data: Partial<TargetSite>): Promise<void> => {
const res = await fetch(`${BASE}/api/sites/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (!res.ok) throw new Error('Failed to update site')
}
export const deleteSite = async (id: number): Promise<void> => {
const res = await fetch(`${BASE}/api/sites/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete site')
}
export const reorderSites = async (order: number[]): Promise<void> => {
const res = await fetch(`${BASE}/api/sites/reorder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }) })
if (!res.ok) throw new Error('Failed to reorder sites')
}
export const adaptSite = async (id: number): Promise<void> => {
const res = await fetch(`${BASE}/api/sites/${id}/adapt`, { method: 'POST' })
if (!res.ok) throw new Error('Adapt failed')
}
export const fetchSiteSelectors = async (id: number): Promise<SiteSelectors | null> => {
const res = await fetch(`${BASE}/api/sites/${id}/selectors`)
if (res.status === 404) return null
if (!res.ok) throw new Error('Failed to fetch selectors')
return res.json()
}
export const deleteSiteSelectors = async (id: number): Promise<void> => {
await fetch(`${BASE}/api/sites/${id}/selectors`, { method: 'DELETE' })
}
```
- [ ] **Step 2: Create frontend/hooks/useSites.ts**
```ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchSites, updateSite, deleteSite, reorderSites, adaptSite, fetchSiteSelectors } from '@/lib/api/sites'
const KEY = ['sites']
export const useSites = () => useQuery({ queryKey: KEY, queryFn: fetchSites })
export const useSiteSelectors = (id: number) => useQuery({ queryKey: ['selectors', id], queryFn: () => fetchSiteSelectors(id), staleTime: 30_000 })
export const useUpdateSite = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial<import('@/lib/types').TargetSite> }) => updateSite(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useDeleteSite = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: deleteSite, onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useReorderSites = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: reorderSites, onSuccess: () => qc.invalidateQueries({ queryKey: KEY }) })
}
export const useAdaptSite = () => {
const qc = useQueryClient()
return useMutation({ mutationFn: adaptSite, onSuccess: (_, id) => qc.invalidateQueries({ queryKey: ['selectors', id] }) })
}
```
- [ ] **Step 3: Create frontend/components/sites/SiteRow.tsx**
```tsx
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useUpdateSite, useDeleteSite, useAdaptSite, useSiteSelectors } from '@/hooks/useSites'
import type { TargetSite } from '@/lib/types'
import { cn } from '@/lib/utils'
function HealthBadge({ site }: { site: TargetSite }) {
const inCooldown = site.cooldown_until && new Date(site.cooldown_until) > new Date()
if (inCooldown) return <span className="text-ghost-gold text-xs font-mono">⏳ COOLDOWN</span>
if (site.consecutive_failures > 2) return <span className="text-ghost-danger text-xs font-mono">✗ {site.error_count} errors</span>
return <span className="text-ghost-accent text-xs font-mono">✓ OK</span>
}
function ConfidenceBadge({ siteId }: { siteId: number }) {
const { data: sel } = useSiteSelectors(siteId)
if (!sel) return <span className="text-ghost-dim text-xs font-mono">—</span>
const color = sel.confidence >= 70 ? 'text-ghost-accent' : sel.confidence >= 40 ? 'text-ghost-gold' : 'text-ghost-danger'
return <span className={cn('text-xs font-mono', color)}>{sel.confidence}%{sel.stale ? ' ⚠' : ''}</span>
}
export default function SiteRow({ site }: { site: TargetSite }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: site.id })
const updateSite = useUpdateSite()
const deleteSite = useDeleteSite()
const adaptSite = useAdaptSite()
const style = { transform: CSS.Transform.toString(transform), transition }
return (
<tr ref={setNodeRef} style={style} className="border-b border-ghost-border hover:bg-ghost-panel/50">
<td className="p-2 w-6 cursor-grab text-ghost-dim" {...attributes} {...listeners}>⋮⋮</td>
<td className="p-2 font-mono text-xs text-ghost-text">{site.name}</td>
<td className="p-2 font-mono text-xs text-ghost-dim truncate max-w-xs">{site.url_template}</td>
<td className="p-2"><HealthBadge site={site} /></td>
<td className="p-2"><ConfidenceBadge siteId={site.id} /></td>
<td className="p-2">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={site.enabled === 1}
onChange={(e) => updateSite.mutate({ id: site.id, data: { enabled: e.target.checked ? 1 : 0 } })}
className="accent-ghost-accent" />
<span className="font-mono text-xs text-ghost-dim">{site.enabled ? 'ON' : 'OFF'}</span>
</label>
</td>
<td className="p-2">
<button onClick={() => adaptSite.mutate(site.id)} disabled={adaptSite.isPending}
className="text-ghost-dim hover:text-ghost-accent font-mono text-xs mr-2">
{adaptSite.isPending ? '⏳' : '🤖'} ADAPT
</button>
<button onClick={() => { if (confirm(`Delete "${site.name}"?`)) deleteSite.mutate(site.id) }}
className="text-ghost-danger font-mono text-xs">✕</button>
</td>
</tr>
)
}
```
- [ ] **Step 4: Create frontend/components/sites/SitesTable.tsx**
```tsx
'use client'
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import SiteRow from './SiteRow'
import { useSites, useReorderSites } from '@/hooks/useSites'
export default function SitesTable() {
const { data: sites, isLoading } = useSites()
const reorder = useReorderSites()
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || !sites) return
const ids = sites.map((s) => s.id)
const from = ids.indexOf(Number(active.id))
const to = ids.indexOf(Number(over.id))
const newOrder = [...ids]
newOrder.splice(to, 0, newOrder.splice(from, 1)[0])
reorder.mutate(newOrder)
}
if (isLoading) return <div className="text-ghost-dim font-mono text-xs animate-pulse">Loading sites…</div>
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={(sites ?? []).map((s) => s.id)} strategy={verticalListSortingStrategy}>
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-ghost-border text-ghost-dim text-left">
<th className="p-2 w-6"></th>
<th className="p-2">NAME</th>
<th className="p-2">URL TEMPLATE</th>
<th className="p-2">HEALTH</th>
<th className="p-2">AI CONF.</th>
<th className="p-2">ENABLED</th>
<th className="p-2">ACTIONS</th>
</tr>
</thead>
<tbody>
{(sites ?? []).map((s) => <SiteRow key={s.id} site={s} />)}
</tbody>
</table>
</SortableContext>
</DndContext>
)
}
```
- [ ] **Step 5: Create frontend/app/sites/page.tsx**
```tsx
import SitesTable from '@/components/sites/SitesTable'
export default function SitesPage() {
return (
<div className="space-y-3">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">TARGET SITES</h1>
<p className="font-mono text-ghost-dim text-xs">AUCTION SOURCES + HEALTH MONITOR</p>
</div>
<SitesTable />
</div>
)
}
```
- [ ] **Step 6: Verify + Commit**
```bash
# verify: open http://localhost:3000/sites — sites table with health badges, adapt buttons, drag handles
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
git add frontend/
git commit -m "feat: Phase 4 complete — Sites tab with health badges, AI adapt, drag-drop"
```
---
### Task 11: Settings Tab
**Files:**
- Create: `frontend/lib/api/config.ts`
- Create: `frontend/lib/api/engine.ts`
- Create: `frontend/lib/api/system.ts`
- Create: `frontend/store/settingsStore.ts`
- Create: `frontend/app/settings/page.tsx`
- [ ] **Step 1: Create frontend/lib/api/config.ts**
```ts
import type { Config } from '@/lib/types'
const BASE = 'http://localhost:8000'
export const fetchConfig = async (): Promise<Record<string, string>> => {
const res = await fetch(`${BASE}/api/config`)
if (!res.ok) throw new Error('Failed to fetch config')
const pairs: Config[] = await res.json()
return Object.fromEntries(pairs.map((c) => [c.key, c.value]))
}
export const saveConfig = async (data: Record<string, string>): Promise<void> => {
const pairs = Object.entries(data).map(([key, value]) => ({ key, value }))
const res = await fetch(`${BASE}/api/config`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pairs),
})
if (!res.ok) throw new Error('Failed to save config')
}
```
- [ ] **Step 2: Create frontend/lib/api/engine.ts**
```ts
const BASE = 'http://localhost:8000'
const call = (path: string) => fetch(`${BASE}/api/engine/${path}`, { method: 'POST' })
export const pauseEngine = () => call('pause')
export const resumeEngine = () => call('resume')
export const restartEngine = () => call('restart')
export const killEngine = () => call('kill')
```
- [ ] **Step 3: Create frontend/lib/api/system.ts**
```ts
const BASE = 'http://localhost:8000'
export const testTelegram = async (): Promise<{ status: string }> => {
const res = await fetch(`${BASE}/api/telegram/test`, { method: 'POST' })
if (!res.ok) throw new Error('Telegram test failed')
return res.json()
}
// File downloads — use window.location, not fetch
export const downloadBackup = () => window.open(`${BASE}/api/backup/download`)
export const restoreBackup = async (file: File): Promise<void> => {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/api/backup/restore`, { method: 'POST', body: form })
if (!res.ok) throw new Error('Restore failed')
}
```
- [ ] **Step 4: Create frontend/store/settingsStore.ts**
```ts
import { create } from 'zustand'
interface SettingsState {
config: Record<string, string>
loaded: boolean
setConfig: (config: Record<string, string>) => void
updateKey: (key: string, value: string) => void
}
export const useSettingsStore = create<SettingsState>((set) => ({
config: {},
loaded: false,
setConfig: (config) => set({ config, loaded: true }),
updateKey: (key, value) => set((s) => ({ config: { ...s.config, [key]: value } })),
}))
```
- [ ] **Step 5: Create frontend/app/settings/page.tsx**
```tsx
'use client'
import { useEffect, useState } from 'react'
import { fetchConfig, saveConfig } from '@/lib/api/config'
import { testTelegram, downloadBackup, restoreBackup } from '@/lib/api/system'
import { useSettingsStore } from '@/store/settingsStore'
const Field = ({ label, k, cfg, onChange, type = 'text' }: { label: string; k: string; cfg: Record<string, string>; onChange: (k: string, v: string) => void; type?: string }) => (
<div className="flex items-center gap-3">
<label className="text-ghost-dim font-mono text-xs w-48 shrink-0">{label}</label>
<input type={type} value={cfg[k] ?? ''} onChange={(e) => onChange(k, e.target.value)}
className="bg-ghost-bg border border-ghost-border text-ghost-text font-mono text-xs px-2 py-1 outline-none focus:border-ghost-accent flex-1" />
</div>
)
export default function SettingsPage() {
const { config, loaded, setConfig, updateKey } = useSettingsStore()
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => { if (!loaded) fetchConfig().then(setConfig) }, [loaded, setConfig])
const save = async () => {
setSaving(true)
try { await saveConfig(config); setMsg('Saved.') }
catch { setMsg('Save failed.') }
setSaving(false)
setTimeout(() => setMsg(''), 3000)
}
const handleRestore = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!confirm('Restore this backup? Current data will be replaced.')) return
await restoreBackup(file)
setMsg('Restored. Restart engine.')
}
if (!loaded) return <div className="text-ghost-dim font-mono text-xs animate-pulse">Loading settings…</div>
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">SETTINGS</h1>
<p className="font-mono text-ghost-dim text-xs">ENGINE CONFIGURATION</p>
</div>
{/* Telegram */}
<Section title="TELEGRAM">
<Field label="Bot Token" k="telegram_token" cfg={config} onChange={updateKey} />
<Field label="Chat ID" k="telegram_chat_id" cfg={config} onChange={updateKey} />
<button onClick={async () => { try { await testTelegram(); setMsg('Telegram OK!') } catch { setMsg('Telegram failed.') } }}
className="btn-ghost mt-2">TEST TELEGRAM</button>
</Section>
{/* Engine */}
<Section title="ENGINE">
<Field label="Scrape interval (s)" k="timer" cfg={config} onChange={updateKey} type="number" />
<Field label="Browser" k="browser_choice" cfg={config} onChange={updateKey} />
<Field label="Humanize level" k="humanize_level" cfg={config} onChange={updateKey} />
<Field label="Show browser" k="show_browser" cfg={config} onChange={updateKey} />
</Section>
{/* Alerts */}
<Section title="ALERTS">
<Field label="Alert channels" k="alert_channels" cfg={config} onChange={updateKey} />
<Field label="Discord webhook" k="discord_webhook" cfg={config} onChange={updateKey} />
<Field label="Gmail address" k="gmail_address" cfg={config} onChange={updateKey} />
<Field label="Gmail app pass" k="gmail_app_password" cfg={config} onChange={updateKey} type="password" />
<Field label="Email to" k="email_to" cfg={config} onChange={updateKey} />
</Section>
{/* AI */}
<Section title="AI FILTER">
<Field label="AI provider" k="ai_provider" cfg={config} onChange={updateKey} />
<Field label="AI model" k="ai_model" cfg={config} onChange={updateKey} />
<Field label="Groq API key" k="ai_api_key" cfg={config} onChange={updateKey} type="password" />
<Field label="AI filter" k="ai_filter_enabled" cfg={config} onChange={updateKey} />
<Field label="Auto-adapt" k="auto_adapt_enabled" cfg={config} onChange={updateKey} />
</Section>
{/* Backup */}
<Section title="BACKUP & RESTORE">
<div className="flex gap-2">
<button onClick={downloadBackup} className="btn-ghost">DOWNLOAD BACKUP</button>
<label className="btn-ghost cursor-pointer">
RESTORE BACKUP
<input type="file" accept=".db" onChange={handleRestore} className="hidden" />
</label>
</div>
</Section>
{/* Save */}
<div className="flex gap-3 items-center pt-2 border-t border-ghost-border">
<button onClick={save} disabled={saving}
className="px-4 py-2 border border-ghost-accent text-ghost-accent font-mono text-xs hover:bg-ghost-accent hover:text-ghost-bg transition-colors">
{saving ? 'SAVING…' : 'SAVE SETTINGS'}
</button>
{msg && <span className="text-ghost-accent font-mono text-xs">{msg}</span>}
</div>
</div>
)
}
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="space-y-2">
<div className="text-ghost-gold font-mono text-xs tracking-wider border-b border-ghost-border pb-1">{title}</div>
{children}
</div>
)
```
- [ ] **Step 6: Verify + Commit**
```bash
# verify: open http://localhost:3000/settings — all config fields, save, telegram test, backup download
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
git add frontend/
git commit -m "feat: Phase 5 complete — Settings tab with all config keys, backup/restore"
```
---
### Task 12: AI Log Tab
**Files:**
- Create: `frontend/components/ai-log/AILogCard.tsx`
- Create: `frontend/components/ai-log/AILogFeed.tsx`
- Create: `frontend/app/ai-log/page.tsx`
- [ ] **Step 1: Create frontend/components/ai-log/AILogCard.tsx**
```tsx
import { cn } from '@/lib/utils'
interface Entry {
id: number
type: 'FILTER' | 'ADAPT' | string
title?: string
ai_target?: string
verdict?: string
reason?: string
tokens?: number
provider?: string
site_name?: string
timestamp: string
error?: string
}
export default function AILogCard({ entry }: { entry: Entry }) {
const isError = !!entry.error
const isMatch = entry.verdict === 'YES'
const isReject = entry.verdict === 'NO'
return (
<div className={cn(
'border p-3 font-mono text-xs space-y-1',
isError ? 'border-ghost-danger bg-ghost-danger/5' :
isMatch ? 'border-ghost-accent/30 bg-ghost-accent/5' :
isReject ? 'border-ghost-border bg-ghost-panel' :
'border-ghost-border bg-ghost-panel'
)}>
<div className="flex justify-between text-ghost-dim">
<span className="text-ghost-gold">[{entry.type}]</span>
<span>{new Date(entry.timestamp).toLocaleTimeString()}</span>
</div>
{entry.title && <div className="text-ghost-text">{entry.title}</div>}
{entry.ai_target && <div className="text-ghost-dim">Target: {entry.ai_target}</div>}
{entry.verdict && (
<div className={isMatch ? 'text-ghost-accent' : 'text-ghost-danger'}>
Verdict: {entry.verdict} — {entry.reason}
</div>
)}
{entry.error && <div className="text-ghost-danger">Error: {entry.error}</div>}
{entry.tokens && <div className="text-ghost-dim">Tokens: {entry.tokens} · {entry.provider}</div>}
</div>
)
}
```
- [ ] **Step 2: Create frontend/components/ai-log/AILogFeed.tsx**
```tsx
'use client'
import { useState, useEffect, useCallback } from 'react'
import AILogCard from './AILogCard'
import { fetchAILog, clearAILog } from '@/lib/api/ai'
type Filter = 'ALL' | 'FILTER' | 'ADAPT' | 'ERRORS'
export default function AILogFeed() {
const [entries, setEntries] = useState<any[]>([])
const [filter, setFilter] = useState<Filter>('ALL')
const [search, setSearch] = useState('')
const load = useCallback(async () => {
try { const data = await fetchAILog(100); setEntries(data) } catch {}
}, [])
useEffect(() => { load(); const t = setInterval(load, 5000); return () => clearInterval(t) }, [load])
const filtered = entries.filter((e) => {
if (filter === 'FILTER' && e.type !== 'FILTER') return false
if (filter === 'ADAPT' && e.type !== 'ADAPT') return false
if (filter === 'ERRORS' && !e.error) return false
if (search && !JSON.stringify(e).toLowerCase().includes(search.toLowerCase())) return false
return true
})
return (
<div className="space-y-3">
<div className="flex gap-2 flex-wrap items-center">
{(['ALL','FILTER','ADAPT','ERRORS'] as Filter[]).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`font-mono text-xs px-3 py-1 border transition-colors ${
filter === f ? 'border-ghost-accent text-ghost-accent' : 'border-ghost-border text-ghost-dim hover:text-ghost-text'
}`}>
{f}
</button>
))}
<input value={search} onChange={(e) => setSearch(e.target.value)}
placeholder="search log..."
className="ml-auto bg-ghost-bg border border-ghost-border text-ghost-text font-mono text-xs px-2 py-1 outline-none focus:border-ghost-accent w-40" />
<button onClick={load} className="btn-ghost">↺ REFRESH</button>
<button onClick={async () => { await clearAILog(); setEntries([]) }} className="btn-danger">🗑 CLEAR</button>
</div>
<div className="space-y-2 max-h-[70vh] overflow-y-auto">
{filtered.length === 0
? <div className="text-ghost-dim font-mono text-xs text-center py-8">NO AI LOG ENTRIES</div>
: filtered.map((e) => <AILogCard key={e.id} entry={e} />)
}
</div>
</div>
)
}
```
- [ ] **Step 3: Create frontend/app/ai-log/page.tsx**
```tsx
import AILogFeed from '@/components/ai-log/AILogFeed'
export default function AILogPage() {
return (
<div className="space-y-3">
<div>
<h1 className="font-mono text-ghost-accent text-sm tracking-widest">AI LOG</h1>
<p className="font-mono text-ghost-dim text-xs">LIVE AI DECISION STREAM</p>
</div>
<AILogFeed />
</div>
)
}
```
- [ ] **Step 4: Run all tests**
```bash
cd frontend && npm test -- --run
```
- [ ] **Step 5: Verify all 6 tabs in browser**
Open each tab and confirm: Dashboard (live stats), Listings (table + detail), Keywords (drag-drop), Sites (health + adapt), Settings (save works), AI Log (cards stream).
- [ ] **Step 6: Commit**
```bash
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
git add frontend/
git commit -m "feat: Phases 5-6 complete — Settings and AI Log tabs"
```
---
## Chunk 5: Phase 7 — Production Serving
### Task 13: Switch FastAPI to serve Next.js build
**Files:**
- Modify: `frontend/next.config.ts` (add `output: 'export'`)
- Modify: `worker.py` (mount static files)
- Modify: `.claude/launch.json` (add frontend dev server entry)
- [ ] **Step 1: Add output: 'export' to next.config.ts**
Add `output: 'export'` to the `nextConfig` object in `frontend/next.config.ts`:
```ts
const nextConfig: NextConfig = {
output: 'export', // ← add this line
async rewrites() { ... },
images: { ... },
}
```
**Note:** `output: 'export'` disables the `/api/stream` SSE route and Next.js middleware in the static build. For the production build, the SSE will fall back to client-side polling (StatusBar degrades gracefully to `setInterval` calling FastAPI directly). Add this note as a comment.
- [ ] **Step 2: Build the Next.js app**
```bash
cd frontend && npm run build
```
Expected: `frontend/out/` directory created with static files. Zero build errors.
- [ ] **Step 3: Add StaticFiles mount to worker.py**
Read `worker.py` first to find where the FastAPI `app` is defined and where routes are declared. Find the line `app = FastAPI(...)` near the top. Add the following at the END of the FastAPI route definitions (after all `@app.get/@app.post` decorators), before the thread startup code:
```python
# ── Phase 7: Serve Next.js static build ─────────────────────────────────────
import pathlib as _pathlib
_frontend_out = _pathlib.Path(__file__).parent / "frontend" / "out"
if _frontend_out.exists():
from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory=str(_frontend_out), html=True), name="frontend")
```
The `if _frontend_out.exists()` guard means: if no build exists yet, FastAPI still serves `dashboard.html` as before. The migration is safe to deploy incrementally.
- [ ] **Step 4: Update .claude/launch.json to add frontend dev server**
```json
{
"version": "0.0.1",
"configurations": [
{
"name": "Ghost Node Backend",
"runtimeExecutable": "python",
"runtimeArgs": ["worker.py"],
"port": 8000
},
{
"name": "Ghost Node Frontend (dev)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "frontend"],
"port": 3000
}
]
}
```
- [ ] **Step 5: Restart backend and verify production build**
```bash
# Stop any running python worker.py, then restart:
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
python worker.py
```
Open `http://localhost:8000`. Expected: Ghost Node React dashboard served by FastAPI. All 6 tabs work. `dashboard.html` is no longer served at `/` — it still exists on disk but is superseded.
- [ ] **Step 6: Final test run**
```bash
cd frontend && npm test -- --run
```
Expected: All tests pass.
- [ ] **Step 7: Final commit**
```bash
cd "C:/Users/Abbas/Documents/Downloads/ClaudeAuction2"
git add frontend/next.config.ts worker.py .claude/launch.json
git commit -m "feat: Phase 7 complete — FastAPI serves Next.js static build, migration complete"
```
---
## Summary
| Phase | Tab | Key deliverable |
|---|---|---|
| 0 | Scaffold | Next.js project, Tailwind theme, SSE, layout shell |
| 1 | Dashboard | Live stats + activity log via Zustand + SSE |
| 2 | Listings | Table + countdown + thumbnails + detail panel |
| 3 | Keywords | Drag-drop + inline edit + batch import |
| 4 | Sites | Health badges + AI adapt + drag-drop |
| 5 | Settings | All 30+ config keys + backup/restore |
| 6 | AI Log | Live AI decision stream + filters |
| 7 | Production | FastAPI serves Next.js build at port 8000 |
*Plan written: 2026-03-11 — Session 16*