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