# 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
().toEqualTypeOf()
})
it('Listing has closing_alerts_sent as number array', () => {
expectTypeOf().toEqualTypeOf()
})
it('TargetSite enabled is 0|1 not boolean', () => {
expectTypeOf().toEqualTypeOf<0 | 1>()
})
it('Stats engine_status is string union', () => {
expectTypeOf().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((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()
expect(screen.getByText(/RUNNING/i)).toBeTruthy()
})
it('formats uptime correctly', () => {
render()
expect(screen.getByText(/1h 1m/i)).toBeTruthy()
})
it('shows offline banner when isOffline', () => {
useEngineStore.setState({ isOffline: true } as any)
render()
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 (
ENGINE OFFLINE — cannot reach localhost:8000
)
}
const statusColor =
status === 'Running' ? 'text-ghost-accent' :
status === 'Paused' ? 'text-ghost-gold' : 'text-ghost-dim'
return (
ENGINE: {status.toUpperCase()}
UPTIME: {formatUptime(uptime_seconds)}
SCANNED: {total_scanned}
ALERTS: {total_alerts}
)
}
```
- [ ] **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 (
)
}
```
- [ ] **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 (
👻
GHOST NODE
AUCTION SNIPER v2.5
)
}
```
- [ ] **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 {children}
}
```
- [ ] **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 (
{children}
)
}
```
- [ ] **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()
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 }) => (
)
export default function StatsGrid({ scanned, alerts, uptime, status }: Props) {
return (
)
}
```
- [ ] **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([
{ id: 0, time: new Date().toLocaleTimeString(), msg: 'Ghost Node dashboard initialized.' },
])
const [filter, setFilter] = useState('')
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [entries])
const filtered = filter
? entries.filter((e) => e.msg.toLowerCase().includes(filter.toLowerCase()))
: entries
return (
// ACTIVITY LOG
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"
/>
{filtered.map((e) => (
[{e.time}]
{e.msg}
))}
)
}
```
- [ ] **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 (
SYSTEM DASHBOARD
REAL-TIME NODE TELEMETRY
)
}
```
- [ ] **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 & {
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 {
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 {
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 {
const res = await fetch(`${BASE}/api/listings`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to clear listings')
}
export async function fetchCountdownSync(): Promise> {
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>({})
const syncedAt = useRef(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()
expect(screen.getByText(/RTX 4090 Gaming GPU/)).toBeTruthy()
})
it('shows AI match badge', () => {
render()
expect(screen.getByTitle(/AI: match/i)).toBeTruthy()
})
it('shows score in gold', () => {
render()
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 🤖✅
if (match === 0) return 🤖❌
return —
}
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 (
{/* Thumbnail */}
{listing.images[0] ? (
{ e.currentTarget.style.display = 'none' }}
/>
) : }
|
{/* Title + location */}
{listing.location && (
📍 {listing.location}
)}
{listing.site_name}
|
{/* Price */}
{listing.price_raw || '—'}
|
{/* Time left */}
{formatMins(mins)}
|
{/* Score */}
{listing.score} |
{/* Keyword */}
{listing.keyword} |
{/* AI badge */}
|
)
}
```
- [ ] **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 (
)
}
```
- [ ] **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 (
{listing && (
{listing.title}
OPEN LOT →
)}
)
}
const Row = ({ label, value, color = 'text-ghost-text' }: { label: string; value: string; color?: string }) => (
{label}
{value}
)
```
- [ ] **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(null)
const [search, setSearch] = useState('')
if (isLoading) return
if (isError) return
const filtered = search
? (listings ?? []).filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()) ||
l.keyword.toLowerCase().includes(search.toLowerCase())
)
: (listings ?? [])
return (
<>
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"
/>
{filtered.length} lots
|
TITLE |
PRICE |
TIME LEFT |
SCORE |
KEYWORD |
AI |
{filtered.map((l, i) => (
))}
{!filtered.length && (
NO LISTINGS CAPTURED YET
)}
setSelected(null)} />
>
)
}
const SkeletonTable = () => (
{Array.from({ length: 5 }).map((_, i) => (
))}
)
const ErrorBanner = () => (
ENGINE OFFLINE — cannot reach localhost:8000
)
```
- [ ] **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 (
LISTINGS
CAPTURED LOTS — LIVE
)
}
```
- [ ] **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 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**
```ts
import type { Keyword } from '@/lib/types'
const BASE = 'http://localhost:8000'
export const fetchKeywords = async (): Promise => {
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 => {
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>): Promise => {
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 => {
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 => {
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[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 (
| ⋮⋮ |
{editTerm ? (
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" />
) : (
setEditTerm(true)} className="cursor-pointer hover:text-ghost-accent text-ghost-text">{keyword.term}
)}
|
{editWeight ? (
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" />
) : (
setEditWeight(true)} className="cursor-pointer text-ghost-gold hover:text-ghost-accent">{keyword.weight}×
)}
|
{keyword.ai_target && 🤖 {keyword.ai_target.slice(0, 30)}…}
|
{(keyword.min_price || keyword.max_price) && (
{keyword.min_price ? `≥$${keyword.min_price}` : ''} {keyword.max_price ? `≤$${keyword.max_price}` : ''}
)}
|
|
)
}
```
- [ ] **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 Loading keywords…
return (
)
}
```
- [ ] **Step 6: Create frontend/app/keywords/page.tsx**
```tsx
import KeywordsTable from '@/components/keywords/KeywordsTable'
export default function KeywordsPage() {
return (
KEYWORDS
SEARCH TERMS + SCORING WEIGHTS
)
}
```
- [ ] **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 => {
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): Promise => {
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): Promise => {
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 => {
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 => {
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 => {
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 => {
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 => {
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 }) => 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 ⏳ COOLDOWN
if (site.consecutive_failures > 2) return ✗ {site.error_count} errors
return ✓ OK
}
function ConfidenceBadge({ siteId }: { siteId: number }) {
const { data: sel } = useSiteSelectors(siteId)
if (!sel) return —
const color = sel.confidence >= 70 ? 'text-ghost-accent' : sel.confidence >= 40 ? 'text-ghost-gold' : 'text-ghost-danger'
return {sel.confidence}%{sel.stale ? ' ⚠' : ''}
}
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 (
| ⋮⋮ |
{site.name} |
{site.url_template} |
|
|
{/* 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. */}
|
|
)
}
```
- [ ] **Step 4: Create frontend/components/sites/SitesTable.tsx**
```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(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 Loading sites…
return (
s.id)} strategy={verticalListSortingStrategy}>
|
NAME |
URL TEMPLATE |
HEALTH |
AI CONF. |
ENABLED |
ACTIONS |
{(sites ?? []).map((s) => )}
)
}
```
- [ ] **Step 5: Create frontend/app/sites/page.tsx**
```tsx
import SitesTable from '@/components/sites/SitesTable'
export default function SitesPage() {
return (
TARGET SITES
AUCTION SOURCES + HEALTH MONITOR
)
}
```
- [ ] **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> => {
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): Promise => {
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 => {
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
loaded: boolean
setConfig: (config: Record) => void
updateKey: (key: string, value: string) => void
}
export const useSettingsStore = create((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; onChange: (k: string, v: string) => void; type?: string }) => (
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" />
)
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) => {
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 Loading settings…
return (
SETTINGS
ENGINE CONFIGURATION
{/* Telegram */}
{/* Engine */}
{/* Alerts */}
{/* AI */}
{/* Backup */}
{/* Save */}
{msg && {msg}}
)
}
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
)
```
- [ ] **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 (
[{entry.type}]
{new Date(entry.timestamp).toLocaleTimeString()}
{entry.title &&
{entry.title}
}
{entry.ai_target &&
Target: {entry.ai_target}
}
{entry.verdict && (
Verdict: {entry.verdict} — {entry.reason}
)}
{entry.error &&
Error: {entry.error}
}
{entry.tokens &&
Tokens: {entry.tokens} · {entry.provider}
}
)
}
```
- [ ] **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([])
const [filter, setFilter] = useState('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 (
{(['ALL','FILTER','ADAPT','ERRORS'] as Filter[]).map((f) => (
))}
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" />
{filtered.length === 0
?
NO AI LOG ENTRIES
: filtered.map((e) =>
)
}
)
}
```
- [ ] **Step 3: Create frontend/app/ai-log/page.tsx**
```tsx
import AILogFeed from '@/components/ai-log/AILogFeed'
export default function AILogPage() {
return (
AI LOG
LIVE AI DECISION STREAM
)
}
```
- [ ] **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*