86 KiB
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
node --version
npm --version
Expected: Node 18+ and npm 9+. If not installed, download from nodejs.org.
- Step 2: Create Next.js app
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
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
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:
"test": "vitest",
"test:ui": "vitest --ui"
And add after "devDependencies":
,
"vitest": {
"environment": "jsdom",
"globals": true,
"setupFiles": ["./vitest.setup.ts"]
}
- Step 6: Create vitest setup file
Create frontend/vitest.setup.ts:
import '@testing-library/jest-dom'
- Step 7: Commit
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:
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
cd frontend && npm test -- --run theme
- Step 3: Replace frontend/next.config.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
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
@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
npm test -- --run theme
- Step 7: Commit
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:
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)
cd frontend && npm test -- --run types
- Step 3: Create frontend/lib/types.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
npm test -- --run types
- Step 5: Commit
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:
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
cd frontend && npm test -- --run engineStore
- Step 3: Create frontend/store/engineStore.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
npm test -- --run engineStore
- Step 5: Create frontend/app/api/stream/route.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
'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
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:
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
cd frontend && npm test -- --run StatusBar
- Step 3: Create frontend/lib/utils.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
'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
npm test -- --run StatusBar
- Step 6: Create frontend/components/layout/Nav.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
'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
'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
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
import { redirect } from 'next/navigation'
export default function Home() { redirect('/dashboard') }
- Step 11: Create frontend/middleware.ts (no-op)
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
cd frontend && npm test -- --run
Expected: All tests pass.
- Step 13: Start dev server and verify
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
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:
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
cd frontend && npm test -- --run StatsGrid
- Step 3: Create frontend/components/dashboard/StatsGrid.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
npm test -- --run StatsGrid
- Step 5: Create frontend/components/dashboard/ActivityLog.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
'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
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
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:
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
cd frontend && npm test -- --run listings-api
- Step 3: Create frontend/lib/api/listings.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
npm test -- --run listings-api
- Step 5: Write countdown hook test
Create frontend/__tests__/useCountdown.test.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
npm test -- --run useCountdown
- Step 7: Create frontend/hooks/useCountdown.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
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
npm test -- --run
- Step 10: Commit
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:
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
cd frontend && npm test -- --run ListingRow
- Step 3: Create frontend/components/listings/ListingRow.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
npm test -- --run ListingRow
- Step 5: Create frontend/components/listings/ImageGallery.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
'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
'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:
@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
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
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
cd ..
git add frontend/
git commit -m "feat: Phase 2 complete — Listings tab with countdown, thumbnails, detail panel"
Chunk 4: Phases 3–6 (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
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
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
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
'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
'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
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
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
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
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
'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, globalShowBrowser }: { site: TargetSite; globalShowBrowser?: boolean | null }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: site.id })
const updateSite = useUpdateSite()
const deleteSite = useDeleteSite()
const adaptSite = useAdaptSite()
const globalShowBrowserOn = globalShowBrowser === true
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">
<div className="flex items-center gap-3">
<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>
{/* Per-site visible override:
- If global `show_browser=true`, it forces visible for all sites.
- Otherwise, `custom_visible_browser=1` enables visible mode for this site only. */}
<label className="flex items-center gap-1 cursor-pointer select-none"
title={globalShowBrowserOn ? 'Global show_browser=true is enabled so per-site override is ignored.' : 'Overrides global show_browser for this site.'}
style={{ opacity: globalShowBrowserOn ? 0.45 : 1, cursor: globalShowBrowserOn ? 'not-allowed' : 'pointer' }}
>
<input
type="checkbox"
checked={site.custom_visible_browser === 1}
disabled={globalShowBrowserOn}
onChange={() => updateSite.mutate({ id: site.id, data: { custom_visible_browser: site.custom_visible_browser ? 0 : 1 } })}
/>
<span className="font-mono text-xs text-ghost-dim">{site.custom_visible_browser ? 'Visible' : 'Headless'}</span>
</label>
</div>
</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
'use client'
import { useEffect, useState } from 'react'
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'
import { fetchConfig } from '@/lib/api/config'
export default function SitesTable() {
const { data: sites, isLoading } = useSites()
const reorder = useReorderSites()
const [globalShowBrowser, setGlobalShowBrowser] = useState<boolean | null>(null)
useEffect(() => {
fetchConfig()
.then((cfg) => setGlobalShowBrowser(String(cfg.show_browser ?? '').toLowerCase() === 'true'))
.catch(() => setGlobalShowBrowser(null))
}, [])
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} globalShowBrowser={globalShowBrowser} />)}
</tbody>
</table>
</SortableContext>
</DndContext>
)
}
- Step 5: Create frontend/app/sites/page.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
# 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
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
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
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
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
'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} />
<Field label="Keyword batching" k="keyword_batch_enabled" 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
# 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
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
'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
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
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
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(addoutput: '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:
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
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:
# ── 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
{
"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
# 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
cd frontend && npm test -- --run
Expected: All tests pass.
- Step 7: Final commit
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