| 'use client' |
| import { useState, useEffect, useRef } from 'react' |
| import { cn } from '@/lib/utils' |
| import { agentMsgs } from '@/lib/mock-data' |
| import { Bot, ChevronDown, ChevronUp, Zap } from 'lucide-react' |
|
|
| interface LiveEvent { |
| ingestionId: string; wallet: string; eventName: string |
| risk?: string; score?: number; firedAt: string; source: string |
| } |
|
|
| const EVENT_EMOJI: Record<string, string> = { |
| churn_risk_high: 'π¨', |
| churn_risk_medium: 'β οΈ', |
| comeback_detected: 'π₯', |
| streak_maintained: 'β‘', |
| volume_milestone: 'π', |
| inactivity_detected: 'π€', |
| referral_from_saved: 'π€', |
| } |
|
|
| function liveEventToMsg(e: LiveEvent): string { |
| const emoji = EVENT_EMOJI[e.eventName] || 'π‘' |
| const short = `${e.wallet.slice(0, 6)}...${e.wallet.slice(-4)}` |
| const label = e.eventName.replace(/_/g, ' ') |
| const score = e.score !== undefined ? ` (score=${e.score})` : '' |
| return `${emoji} LIVE: ${label} β ${short}${score} [${e.ingestionId.slice(0, 8)}]` |
| } |
|
|
| export function AgentFeed() { |
| const [msgs, setMsgs] = useState<{ text: string; time: string; live?: boolean }[]>([]) |
| const [open, setOpen] = useState(true) |
| const [liveCount, setLiveCount] = useState(0) |
| const seenIds = useRef(new Set<string>()) |
|
|
| |
| useEffect(() => { |
| const init = agentMsgs.slice(0, 5).map((t, i) => ({ |
| text: t, |
| time: new Date(Date.now() - i * 120000).toLocaleTimeString('en-US', { hour12: false }), |
| })) |
| setMsgs(init) |
| let c = 5 |
| const iv = setInterval(() => { |
| const t = agentMsgs[c++ % agentMsgs.length] |
| setMsgs(p => [{ text: t, time: new Date().toLocaleTimeString('en-US', { hour12: false }) }, ...p].slice(0, 30)) |
| }, 4000) |
| return () => clearInterval(iv) |
| }, []) |
|
|
| |
| useEffect(() => { |
| const poll = async () => { |
| try { |
| const res = await fetch('/api/torque/events/recent?limit=10') |
| if (!res.ok) return |
| const data = await res.json() |
| const events: LiveEvent[] = data.events || [] |
| const newEvents = events.filter(e => !seenIds.current.has(e.ingestionId)) |
| if (newEvents.length === 0) return |
| newEvents.forEach(e => seenIds.current.add(e.ingestionId)) |
| const now = new Date().toLocaleTimeString('en-US', { hour12: false }) |
| const liveLines = newEvents.map(e => ({ text: liveEventToMsg(e), time: now, live: true })) |
| setMsgs(p => [...liveLines, ...p].slice(0, 30)) |
| setLiveCount(data.total || 0) |
| } catch {} |
| } |
| poll() |
| const iv = setInterval(poll, 4000) |
| return () => clearInterval(iv) |
| }, []) |
|
|
| return ( |
| <div className="rounded-xl bg-surface-card border border-hairline-dark overflow-hidden"> |
| <button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-5 py-3 border-b border-hairline-dark hover:bg-surface-elevated transition"> |
| <div className="flex items-center gap-2"> |
| <Bot className="w-4 h-4 text-brand-yellow" /> |
| <span className="text-title-sm">AI Agent Feed</span> |
| <div className="w-2 h-2 rounded-full bg-trading-up animate-pulse" /> |
| {liveCount > 0 && ( |
| <div className="flex items-center gap-1 px-2 py-0.5 rounded-pill bg-trading-up/10 border border-trading-up/20"> |
| <Zap className="w-2.5 h-2.5 text-trading-up" /> |
| <span className="text-[10px] text-trading-up font-semibold">{liveCount} live</span> |
| </div> |
| )} |
| </div> |
| {open ? <ChevronUp className="w-4 h-4 text-muted" /> : <ChevronDown className="w-4 h-4 text-muted" />} |
| </button> |
| {open && ( |
| <div className="max-h-[300px] overflow-y-auto"> |
| {msgs.map((m, i) => ( |
| <div key={i} className={cn( |
| 'flex items-start gap-3 px-5 py-2.5 border-b border-hairline-dark/50 transition-colors', |
| i === 0 && 'animate-slide-up', |
| m.live ? 'bg-trading-up/5 border-l-2 border-trading-up/50' : i === 0 && 'bg-brand-yellow/5' |
| )}> |
| <span className="font-mono text-caption text-muted tabular-nums whitespace-nowrap mt-0.5">{m.time}</span> |
| <span className={cn('text-body-sm flex-1', m.live ? 'text-[#eaecef] font-medium' : 'text-[#eaecef]')}>{m.text}</span> |
| {m.live && <span className="text-[10px] text-trading-up font-semibold uppercase tracking-wider whitespace-nowrap">LIVE</span>} |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|