39246-vm/docs/superpowers/plans/2026-03-11-frontend-migration.md
2026-03-20 02:38:40 +03:00

86 KiB
Raw Permalink Blame History

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 36 (Keywords, Sites, Settings, AI Log)

Task 9: Keywords API + Tab

Files:

  • Create: frontend/lib/api/keywords.ts

  • Create: frontend/lib/api/ai.ts

  • Create: frontend/hooks/useKeywords.ts

  • Create: frontend/components/keywords/KeywordsTable.tsx

  • Create: frontend/components/keywords/KeywordRow.tsx

  • Create: frontend/app/keywords/page.tsx

  • Step 1: Create frontend/lib/api/keywords.ts

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 (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:

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