flowstate / src /app /(dashboard) /page.tsx
muthuk1's picture
feat: full Torque integration — live events, toast, bulk rescue, auto-scan
c6b6c96
'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])
// Auto-scan countdown + trigger
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])
// Keyboard shortcut: S = scan
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>
)
}