| 'use client' |
|
|
| import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' |
| import { Users, ShieldAlert, HeartPulse, TrendingUp, Bot, Flame, ArrowUpRight, ArrowDownRight, Scan, Zap, ExternalLink, Timer, ToggleLeft, ToggleRight } from 'lucide-react' |
| import { StatCard } from '@/components/ui/StatCard' |
| import { CampaignBadge } from '@/components/ui/CampaignBadge' |
| import { AgentFeed } from '@/components/ui/AgentFeed' |
| import { useToast } from '@/components/ui/Toast' |
| import { cn, fmtNum, fmtUsd } from '@/lib/utils' |
| import { stats, events, campaigns, retentionData, churnData, protocols } from '@/lib/mock-data' |
| import type { CampaignType } from '@/lib/types' |
| import { useState, useEffect, useCallback, useRef } from 'react' |
|
|
| const Tip = ({ active, payload, label }: any) => { |
| if (!active || !payload?.length) return null |
| return (<div className="bg-surface-elevated border border-hairline-dark rounded-lg p-3 shadow-lg"> |
| <p className="text-caption text-muted mb-1">{label}</p> |
| <p className="font-mono text-num-md text-[#eaecef]">{payload[0].value}</p> |
| </div>) |
| } |
|
|
| const riskDist = [ |
| { name: 'Safe', value: 45, color: '#2dbdb6' }, { name: 'Low', value: 25, color: '#0ecb81' }, |
| { name: 'Medium', value: 15, color: '#FCD535' }, { name: 'High', value: 10, color: '#ff9500' }, |
| { name: 'Critical', value: 5, color: '#f6465d' }, |
| ] |
|
|
| interface LiveEvent { |
| ingestionId: string; wallet: string; eventName: string |
| risk?: string; score?: number; firedAt: string; source: string |
| } |
|
|
| const RISK_COLOR: Record<string, string> = { |
| critical: '#f6465d', high: '#ff9500', medium: '#FCD535', low: '#0ecb81', safe: '#2dbdb6' |
| } |
|
|
| const AUTO_SCAN_INTERVAL = 30 |
|
|
| export default function DashboardPage() { |
| const active = campaigns.filter(c => c.status === 'active') |
| const { fire: toast } = useToast() |
| const [scanning, setScanning] = useState(false) |
| const [autoScan, setAutoScan] = useState(false) |
| const [countdown, setCountdown] = useState(AUTO_SCAN_INTERVAL) |
| const [liveEvents, setLiveEvents] = useState<LiveEvent[]>([]) |
| const [sessionCount, setSessionCount] = useState(0) |
| const [torqueStatus, setTorqueStatus] = useState<'connected' | 'unconfigured' | null>(null) |
| const prevCountRef = useRef(0) |
|
|
| useEffect(() => { |
| fetch('/api/torque/status').then(r => r.json()).then(d => { |
| setTorqueStatus(d.configured ? 'connected' : 'unconfigured') |
| setSessionCount(d.sessionEvents || 0) |
| }).catch(() => setTorqueStatus('unconfigured')) |
| }, []) |
|
|
| useEffect(() => { |
| const poll = () => { |
| fetch('/api/torque/events/recent?limit=8').then(r => r.json()).then(d => { |
| const newEvents: LiveEvent[] = d.events || [] |
| const newCount: number = d.total || 0 |
| if (newCount > prevCountRef.current && prevCountRef.current > 0) { |
| const latest = newEvents[0] |
| if (latest) { |
| toast({ |
| type: 'event', |
| title: latest.eventName.replace(/_/g, ' ').toUpperCase(), |
| body: `${latest.wallet.slice(0, 8)}...${latest.wallet.slice(-4)} · ${latest.ingestionId.slice(0, 8)}`, |
| }) |
| } |
| } |
| prevCountRef.current = newCount |
| setLiveEvents(newEvents) |
| setSessionCount(newCount) |
| }).catch(() => {}) |
| } |
| poll() |
| const iv = setInterval(poll, 5000) |
| return () => clearInterval(iv) |
| }, [toast]) |
|
|
| const triggerScan = useCallback(async () => { |
| if (scanning) return |
| setScanning(true) |
| try { |
| const res = await fetch('/api/agent/scan', { method: 'POST' }) |
| const data = await res.json() |
| if (data.configured) { |
| toast({ type: 'success', title: `Scan complete — ${data.count} at-risk wallets`, body: `${data.detections?.filter((d: any) => d.eventSent).length || 0} events fired to Torque` }) |
| } else { |
| toast({ type: 'error', title: 'Torque not configured', body: 'Set TORQUE_INGEST_KEY in .env.local' }) |
| } |
| const d = await fetch('/api/torque/events/recent?limit=8').then(r => r.json()) |
| setLiveEvents(d.events || []) |
| setSessionCount(d.total || 0) |
| } catch { |
| toast({ type: 'error', title: 'Scan failed', body: 'Check server logs' }) |
| } finally { |
| setScanning(false) |
| setCountdown(AUTO_SCAN_INTERVAL) |
| } |
| }, [scanning, toast]) |
|
|
| |
| useEffect(() => { |
| if (!autoScan) { setCountdown(AUTO_SCAN_INTERVAL); return } |
| const tick = setInterval(() => { |
| setCountdown(c => { |
| if (c <= 1) { triggerScan(); return AUTO_SCAN_INTERVAL } |
| return c - 1 |
| }) |
| }, 1000) |
| return () => clearInterval(tick) |
| }, [autoScan, triggerScan]) |
|
|
| |
| useEffect(() => { |
| const handler = (e: KeyboardEvent) => { |
| if (e.key === 's' && !e.metaKey && !e.ctrlKey && e.target === document.body) triggerScan() |
| } |
| document.addEventListener('keydown', handler) |
| return () => document.removeEventListener('keydown', handler) |
| }, [triggerScan]) |
|
|
| return ( |
| <div className="p-6 space-y-6"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <h1 className="text-display-sm text-[#eaecef]">Dashboard</h1> |
| <p className="text-body-md text-muted mt-1">Real-time churn detection and autonomous retention</p> |
| </div> |
| <div className="flex items-center gap-3"> |
| {torqueStatus !== null && ( |
| <div className={cn('flex items-center gap-2 px-3 py-1.5 rounded-lg border text-caption font-semibold', |
| torqueStatus === 'connected' |
| ? 'bg-trading-up/10 border-trading-up/30 text-trading-up' |
| : 'bg-trading-down/10 border-trading-down/30 text-trading-down')}> |
| <div className={cn('w-2 h-2 rounded-full', torqueStatus === 'connected' ? 'bg-trading-up animate-pulse' : 'bg-trading-down')} /> |
| {torqueStatus === 'connected' ? 'Torque Connected' : 'Torque Unconfigured'} |
| </div> |
| )} |
| {sessionCount > 0 && ( |
| <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-brand-yellow/30 bg-brand-yellow/5"> |
| <Zap className="w-3.5 h-3.5 text-brand-yellow" /> |
| <span className="text-caption text-brand-yellow font-semibold tabular-nums">{sessionCount} live events</span> |
| </div> |
| )} |
| {/* Auto-scan toggle */} |
| <button |
| onClick={() => setAutoScan(v => !v)} |
| className={cn('flex items-center gap-2 px-3 py-1.5 rounded-lg border text-caption font-semibold transition-all', |
| autoScan ? 'bg-brand-turquoise/10 border-brand-turquoise/30 text-brand-turquoise' : 'bg-surface-card border-hairline-dark text-muted hover:text-[#eaecef]')} |
| > |
| {autoScan ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />} |
| Auto |
| {autoScan && ( |
| <span className="flex items-center gap-1"> |
| <Timer className="w-3 h-3" /> |
| <span className="font-mono tabular-nums">{countdown}s</span> |
| </span> |
| )} |
| </button> |
| <button |
| onClick={triggerScan} |
| disabled={scanning} |
| className="flex items-center gap-2 px-4 py-2 rounded-md bg-brand-yellow text-ink text-button font-semibold hover:bg-brand-yellow-active transition disabled:opacity-60" |
| title="Press S to scan" |
| > |
| <Scan className={cn('w-4 h-4', scanning && 'animate-spin')} /> |
| {scanning ? 'Scanning...' : 'Scan Now'} |
| </button> |
| </div> |
| </div> |
| |
| <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> |
| <StatCard title="Active Wallets" value={fmtNum(stats.activeWallets)} change={12.3} changeLabel="vs last week" icon={Users} variant="yellow" /> |
| <StatCard title="Wallets At Risk" value={fmtNum(stats.walletsAtRisk)} change={-8.7} changeLabel="vs last week" icon={ShieldAlert} variant="red" /> |
| <StatCard title="Wallets Saved" value={fmtNum(stats.walletsSaved)} change={23.4} changeLabel="vs last week" icon={HeartPulse} variant="green" /> |
| <StatCard title="ROI" value={stats.roi + '%'} change={15.2} changeLabel="vs last week" icon={TrendingUp} variant="yellow" /> |
| </div> |
| |
| {/* Live Torque Events Strip */} |
| {liveEvents.length > 0 && ( |
| <div className="rounded-xl bg-surface-card border border-trading-up/20 overflow-hidden"> |
| <div className="flex items-center gap-3 px-5 py-3 border-b border-trading-up/10 bg-trading-up/5"> |
| <div className="w-2 h-2 rounded-full bg-trading-up animate-pulse" /> |
| <span className="text-caption text-trading-up font-semibold uppercase tracking-wider">Live Torque Events</span> |
| <span className="text-caption text-muted">{sessionCount} fired this session</span> |
| <a href="https://platform.torque.so" target="_blank" rel="noreferrer" className="ml-auto flex items-center gap-1 text-caption text-trading-up/70 hover:text-trading-up transition"> |
| <ExternalLink className="w-3 h-3" />View on Torque |
| </a> |
| </div> |
| <div className="divide-y divide-hairline-dark/30"> |
| {liveEvents.slice(0, 5).map((e, i) => ( |
| <div key={e.ingestionId} className={cn('flex items-center gap-4 px-5 py-2.5 group', i === 0 && 'bg-trading-up/5')}> |
| <div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: RISK_COLOR[e.risk || 'safe'] || '#0ecb81' }} /> |
| <code className="font-mono text-caption text-brand-yellow">{e.eventName}</code> |
| <span className="font-mono text-caption text-muted">{e.wallet.slice(0, 8)}...{e.wallet.slice(-4)}</span> |
| {e.score !== undefined && <span className="font-mono text-caption text-muted">score={e.score}</span>} |
| <span className="ml-auto font-mono text-[10px] text-muted/60 group-hover:text-muted transition">{e.ingestionId.slice(0, 12)}...</span> |
| <span className="text-[10px] text-trading-up font-semibold uppercase">LIVE</span> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Auto-scan active banner */} |
| {autoScan && ( |
| <div className="rounded-xl bg-brand-turquoise/5 border border-brand-turquoise/20 px-5 py-3 flex items-center gap-3"> |
| <div className="w-2 h-2 rounded-full bg-brand-turquoise animate-pulse" /> |
| <span className="text-caption text-brand-turquoise font-semibold">Auto-scan active</span> |
| <span className="text-caption text-muted">Next scan in <span className="font-mono text-brand-turquoise">{countdown}s</span> — firing real Torque events for at-risk wallets</span> |
| <button onClick={() => setAutoScan(false)} className="ml-auto text-caption text-muted hover:text-trading-down transition">Stop</button> |
| </div> |
| )} |
| |
| <AgentFeed /> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <div className="flex items-center justify-between mb-4"> |
| <div><h3 className="text-title-sm">Retention Rate</h3><p className="text-caption text-muted mt-0.5">30-day trailing average</p></div> |
| <div className="flex items-center gap-1"><ArrowUpRight className="w-4 h-4 text-trading-up" /><span className="font-mono text-num-sm text-trading-up">+9.6%</span></div> |
| </div> |
| <ResponsiveContainer width="100%" height={220}> |
| <AreaChart data={retentionData}> |
| <defs><linearGradient id="rg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#0ecb81" stopOpacity={0.3} /><stop offset="95%" stopColor="#0ecb81" stopOpacity={0} /></linearGradient></defs> |
| <CartesianGrid strokeDasharray="3 3" stroke="#2b3139" /><XAxis dataKey="date" tick={{ fill: '#707a8a', fontSize: 11 }} axisLine={{ stroke: '#2b3139' }} /><YAxis tick={{ fill: '#707a8a', fontSize: 11 }} axisLine={{ stroke: '#2b3139' }} domain={[50, 75]} /> |
| <Tooltip content={<Tip />} /><Area type="monotone" dataKey="value" stroke="#0ecb81" fill="url(#rg)" strokeWidth={2} /> |
| </AreaChart> |
| </ResponsiveContainer> |
| </div> |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <div className="flex items-center justify-between mb-4"> |
| <div><h3 className="text-title-sm">Churn Rate</h3><p className="text-caption text-muted mt-0.5">Daily churn percentage</p></div> |
| <div className="flex items-center gap-1"><ArrowDownRight className="w-4 h-4 text-trading-up" /><span className="font-mono text-num-sm text-trading-up">-4.3%</span></div> |
| </div> |
| <ResponsiveContainer width="100%" height={220}> |
| <AreaChart data={churnData}> |
| <defs><linearGradient id="cg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#f6465d" stopOpacity={0.3} /><stop offset="95%" stopColor="#f6465d" stopOpacity={0} /></linearGradient></defs> |
| <CartesianGrid strokeDasharray="3 3" stroke="#2b3139" /><XAxis dataKey="date" tick={{ fill: '#707a8a', fontSize: 11 }} axisLine={{ stroke: '#2b3139' }} /><YAxis tick={{ fill: '#707a8a', fontSize: 11 }} axisLine={{ stroke: '#2b3139' }} domain={[0, 10]} /> |
| <Tooltip content={<Tip />} /><Area type="monotone" dataKey="value" stroke="#f6465d" fill="url(#cg)" strokeWidth={2} /> |
| </AreaChart> |
| </ResponsiveContainer> |
| </div> |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <h3 className="text-title-sm mb-4">Risk Distribution</h3> |
| <div className="flex items-center gap-6"> |
| <ResponsiveContainer width={140} height={140}><PieChart><Pie data={riskDist} innerRadius={40} outerRadius={65} paddingAngle={3} dataKey="value">{riskDist.map((e, i) => <Cell key={i} fill={e.color} />)}</Pie></PieChart></ResponsiveContainer> |
| <div className="space-y-2">{riskDist.map(r => (<div key={r.name} className="flex items-center gap-2"><div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: r.color }} /><span className="text-caption text-muted">{r.name}</span><span className="font-mono text-caption text-[#eaecef] ml-auto">{r.value}%</span></div>))}</div> |
| </div> |
| </div> |
| |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <h3 className="text-title-sm mb-4">Recent Events</h3> |
| <div className="space-y-3">{events.slice(0, 5).map(e => ( |
| <div key={e.id} className="flex items-start gap-3 pb-3 border-b border-hairline-dark/50 last:border-0"> |
| <div className={cn('w-2 h-2 rounded-full mt-1.5 flex-shrink-0', e.resolved ? 'bg-trading-up' : 'bg-trading-down animate-pulse')} /> |
| <div className="min-w-0"><p className="text-body-sm text-[#eaecef] truncate">{e.eventType.replace(/_/g, ' ').toUpperCase()}</p><p className="text-caption text-muted font-mono">{e.wallet}</p></div> |
| <span className="text-caption text-muted ml-auto whitespace-nowrap">{e.timestamp}</span> |
| </div> |
| ))}</div> |
| </div> |
| |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <h3 className="text-title-sm mb-4">Active Campaigns</h3> |
| <div className="space-y-3">{active.slice(0, 4).map(c => ( |
| <div key={c.id} className="flex items-center gap-3 pb-3 border-b border-hairline-dark/50 last:border-0"> |
| <CampaignBadge type={c.type as CampaignType} /> |
| <div className="min-w-0 flex-1"><p className="text-body-sm text-[#eaecef] truncate">{c.name}</p><p className="text-caption text-muted font-mono">{fmtNum(c.participantCount)} users</p></div> |
| {c.createdBy === 'ai-agent' && <Bot className="w-4 h-4 text-brand-yellow flex-shrink-0" />} |
| </div> |
| ))}</div> |
| </div> |
| </div> |
| |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <h3 className="text-title-sm mb-4">Protocol Performance</h3> |
| <table className="w-full"><thead><tr className="border-b border-hairline-dark"> |
| <th className="text-left text-caption text-muted uppercase tracking-wider px-4 py-3">Protocol</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-4 py-3">Volume</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-4 py-3">Users</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-4 py-3">Churn</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-4 py-3">Retention</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-4 py-3">Avg Streak</th> |
| </tr></thead><tbody>{protocols.map(p => ( |
| <tr key={p.protocol} className="border-b border-hairline-dark/50 hover:bg-surface-hover transition-colors"> |
| <td className="px-4 py-3"><div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full" style={{ backgroundColor: p.color }} /><span className="text-body-md font-medium">{p.protocol}</span></div></td> |
| <td className="text-right px-4 py-3 font-mono text-num-md tabular-nums">{fmtUsd(p.volume)}</td> |
| <td className="text-right px-4 py-3 font-mono text-num-md tabular-nums">{fmtNum(p.users)}</td> |
| <td className="text-right px-4 py-3"><span className={cn('font-mono text-num-sm', p.churnRate <= 4 ? 'text-trading-up' : 'text-trading-down')}>{p.churnRate}%</span></td> |
| <td className="text-right px-4 py-3"><span className={cn('font-mono text-num-sm', p.retentionRate >= 70 ? 'text-trading-up' : 'text-brand-yellow')}>{p.retentionRate}%</span></td> |
| <td className="text-right px-4 py-3"><div className="flex items-center justify-end gap-1"><Flame className="w-3.5 h-3.5 text-brand-yellow" /><span className="font-mono text-num-sm">{p.avgStreak}d</span></div></td> |
| </tr> |
| ))}</tbody></table> |
| </div> |
| </div> |
| ) |
| } |
|
|