| 'use client' |
| import { cn, fmtUsd, shortAddr } from '@/lib/utils' |
| import { wallets } from '@/lib/mock-data' |
| import { RiskBadge } from '@/components/ui/RiskBadge' |
| import { RecoveryCard } from '@/components/ui/RecoveryCard' |
| import { useToast } from '@/components/ui/Toast' |
| import { Search, Flame, ExternalLink, ChevronRight, Zap, CheckCircle2, Loader2, Siren, Share2, AlertTriangle, Radio } from 'lucide-react' |
| import { useState, useCallback, useMemo } from 'react' |
| import type { ChurnRisk, Wallet } from '@/lib/types' |
| import { classifyWalletType } from '@/lib/agent-engine' |
|
|
| |
| interface LiveResult { |
| address: string; score: number; risk: string; detectedSignals: string[] |
| signals: { daysInactive: number; protocols: string[]; currentStreak: number; volumeDropPct: number; totalTxLast30d: number; lastActiveDaysAgo: string } |
| torque: { fired: boolean; eventId?: string; eventName?: string } |
| } |
|
|
| function LiveAnalyzer() { |
| const [addr, setAddr] = useState('') |
| const [loading, setLoading] = useState(false) |
| const [result, setResult] = useState<LiveResult | null>(null) |
| const [err, setErr] = useState<string | null>(null) |
| const [autoFire, setAutoFire] = useState(false) |
| const { fire: toast } = useToast() |
|
|
| const analyze = useCallback(async () => { |
| if (!addr.trim() || loading) return |
| setLoading(true); setErr(null); setResult(null) |
| try { |
| const res = await fetch('/api/helius/analyze', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ address: addr.trim(), autoFire }), |
| }) |
| const data = await res.json() |
| if (!res.ok) { setErr(data.error); return } |
| setResult(data) |
| if (data.torque?.fired) { |
| toast({ type: 'event', title: `${data.torque.eventName} → ${addr.slice(0,8)}...`, body: data.torque.eventId?.slice(0,10) }) |
| } |
| } catch (e: any) { |
| setErr(e.message) |
| } finally { |
| setLoading(false) |
| } |
| }, [addr, autoFire, loading, toast]) |
|
|
| const RISK_CLR: Record<string, string> = { critical:'text-trading-down', high:'text-[#ff9500]', medium:'text-brand-yellow', low:'text-trading-up', safe:'text-muted' } |
| const RISK_BG: Record<string, string> = { critical:'border-trading-down/30 bg-trading-down/5', high:'border-[#ff9500]/30 bg-[#ff9500]/5', medium:'border-brand-yellow/30 bg-brand-yellow/5', low:'border-trading-up/30 bg-trading-up/5', safe:'border-hairline-dark bg-surface-elevated' } |
|
|
| return ( |
| <div className="rounded-xl bg-surface-card border border-hairline-dark overflow-hidden"> |
| <div className="px-5 py-4 border-b border-hairline-dark flex items-center gap-2"> |
| <Radio className="w-4 h-4 text-trading-up" /> |
| <h3 className="text-title-sm">Live Wallet Analyzer</h3> |
| <span className="text-[10px] px-2 py-0.5 rounded-pill bg-trading-up/10 text-trading-up font-semibold border border-trading-up/20">HELIUS</span> |
| <p className="text-caption text-muted ml-1">Enter any Solana address — real on-chain churn score</p> |
| </div> |
| <div className="p-5 space-y-4"> |
| <div className="flex gap-2"> |
| <input |
| value={addr} onChange={e => setAddr(e.target.value)} |
| onKeyDown={e => e.key === 'Enter' && analyze()} |
| placeholder="Enter Solana wallet address..." |
| className="flex-1 h-10 px-4 rounded-lg bg-surface-elevated border border-hairline-dark text-body-sm text-[#eaecef] placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-brand-yellow/50 font-mono" |
| /> |
| <label className="flex items-center gap-2 px-3 rounded-lg border border-hairline-dark bg-surface-elevated cursor-pointer select-none"> |
| <input type="checkbox" checked={autoFire} onChange={e => setAutoFire(e.target.checked)} className="accent-brand-yellow" /> |
| <span className="text-caption text-muted whitespace-nowrap">Auto-fire Torque</span> |
| </label> |
| <button onClick={analyze} disabled={loading || !addr.trim()} |
| className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-yellow text-ink text-button font-semibold hover:bg-brand-yellow-active transition disabled:opacity-50"> |
| {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />} |
| {loading ? 'Scanning...' : 'Analyze'} |
| </button> |
| </div> |
| |
| {err && <p className="text-body-sm text-trading-down px-1">Error: {err}</p>} |
| |
| {result && ( |
| <div className={cn('rounded-xl border p-4 space-y-3', RISK_BG[result.risk] || 'border-hairline-dark')}> |
| <div className="flex items-center justify-between"> |
| <div> |
| <p className="font-mono text-body-sm text-[#eaecef]">{result.address.slice(0,8)}...{result.address.slice(-6)}</p> |
| <a href={`https://solscan.io/account/${result.address}`} target="_blank" rel="noreferrer" className="text-caption text-muted hover:text-brand-yellow transition flex items-center gap-1 mt-0.5"> |
| <ExternalLink className="w-3 h-3" />View on Solscan |
| </a> |
| </div> |
| <div className="text-right"> |
| <p className={cn('font-mono text-display-sm font-bold tabular-nums', RISK_CLR[result.risk])}>{result.score}</p> |
| <p className={cn('text-caption font-bold uppercase', RISK_CLR[result.risk])}>{result.risk}</p> |
| </div> |
| </div> |
| <div className="grid grid-cols-4 gap-3"> |
| <div className="rounded-lg bg-surface-elevated p-2.5 text-center"> |
| <p className="text-[10px] text-muted">Inactive</p> |
| <p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.lastActiveDaysAgo}</p> |
| </div> |
| <div className="rounded-lg bg-surface-elevated p-2.5 text-center"> |
| <p className="text-[10px] text-muted">Streak</p> |
| <p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.currentStreak}d</p> |
| </div> |
| <div className="rounded-lg bg-surface-elevated p-2.5 text-center"> |
| <p className="text-[10px] text-muted">Vol drop</p> |
| <p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.volumeDropPct}%</p> |
| </div> |
| <div className="rounded-lg bg-surface-elevated p-2.5 text-center"> |
| <p className="text-[10px] text-muted">30d txs</p> |
| <p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.totalTxLast30d}</p> |
| </div> |
| </div> |
| {result.signals.protocols.length > 0 && ( |
| <div className="flex flex-wrap gap-1"> |
| {result.signals.protocols.map(p => <span key={p} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">{p}</span>)} |
| </div> |
| )} |
| {result.detectedSignals.length > 0 && ( |
| <div className="space-y-1"> |
| {result.detectedSignals.map((s, i) => <p key={i} className="text-caption text-muted flex items-center gap-1.5"><span className="text-trading-down">↑</span>{s}</p>)} |
| </div> |
| )} |
| {result.torque.fired && ( |
| <div className="flex items-center gap-2 p-2.5 rounded-lg bg-trading-up/10 border border-trading-up/30"> |
| <CheckCircle2 className="w-4 h-4 text-trading-up flex-shrink-0" /> |
| <div> |
| <p className="text-caption text-trading-up font-semibold">{result.torque.eventName} fired via Torque</p> |
| <p className="font-mono text-[10px] text-muted">{result.torque.eventId}</p> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ) |
| } |
|
|
| type Filter = ChurnRisk | 'all' |
| const filters: Filter[] = ['all', 'critical', 'high', 'medium', 'low', 'safe'] |
|
|
| const EVENT_MAP: Record<ChurnRisk, string> = { |
| critical: 'churn_risk_high', high: 'churn_risk_high', |
| medium: 'churn_risk_medium', low: 'inactivity_detected', safe: 'streak_maintained', |
| } |
| const ACTION_LABEL: Record<ChurnRisk, string> = { |
| critical: 'Send Gift', high: 'Enter Raffle', medium: 'Activate Rebate', |
| low: 'Flag Inactive', safe: 'Reward Streak', |
| } |
| const RISK_CAN_INTERVENE: ChurnRisk[] = ['critical', 'high', 'medium'] |
|
|
| |
| const RESCUED_WALLETS = new Set(wallets.filter(w => w.savedCount > 0).map(w => w.address)) |
|
|
| |
| function isPreChurnWarning(w: Wallet): boolean { |
| const daysMatch = w.lastActive.match(/(\d+)d/) |
| const days = daysMatch ? parseInt(daysMatch[1]) : 0 |
| const wtype = classifyWalletType(w.protocols) |
| const threshold = wtype === 'trader' ? 3 : wtype === 'lp' ? 5 : 7 |
| return w.churnRisk === 'low' && days >= threshold && w.streak <= 2 |
| } |
|
|
| export default function WalletsPage() { |
| const { fire: toast } = useToast() |
| const [f, setF] = useState<Filter>('all') |
| const [q, setQ] = useState('') |
| const [sort, setSort] = useState<'risk' | 'volume' | 'streak'>('risk') |
| const [firing, setFiring] = useState<string | null>(null) |
| const [fired, setFired] = useState<Map<string, string>>(new Map()) |
| const [bulkFiring, setBulkFiring] = useState(false) |
| const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null) |
| const [recoveryWallet, setRecoveryWallet] = useState<Wallet | null>(null) |
|
|
| const preChurnCount = useMemo(() => wallets.filter(isPreChurnWarning).length, []) |
|
|
| const list = wallets |
| .filter(w => f === 'all' || w.churnRisk === f) |
| .filter(w => !q || w.address.toLowerCase().includes(q.toLowerCase())) |
| .sort((a, b) => sort === 'risk' ? b.riskScore - a.riskScore : sort === 'volume' ? b.totalVolume - a.totalVolume : b.streak - a.streak) |
|
|
| const criticalUnfired = wallets.filter(w => |
| (w.churnRisk === 'critical' || w.churnRisk === 'high') && !fired.has(w.address) |
| ) |
|
|
| const intervene = useCallback(async (w: Wallet) => { |
| if (firing || fired.has(w.address)) return |
| setFiring(w.address) |
| try { |
| const res = await fetch('/api/torque/events', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| wallet: w.address, |
| eventName: EVENT_MAP[w.churnRisk], |
| data: { risk: w.churnRisk, score: w.riskScore, detectedBy: 'flowstate-dashboard' }, |
| risk: w.churnRisk, |
| score: w.riskScore, |
| }), |
| }) |
| const data = await res.json() |
| const eventId = data.eventId || 'sent' |
| setFired(prev => new Map(prev).set(w.address, eventId)) |
| toast({ |
| type: 'event', |
| title: `${ACTION_LABEL[w.churnRisk]} → ${shortAddr(w.address)}`, |
| body: `${EVENT_MAP[w.churnRisk]} · ${eventId.slice(0, 10)}`, |
| }) |
| } catch { |
| setFired(prev => new Map(prev).set(w.address, 'error')) |
| toast({ type: 'error', title: 'Event failed', body: w.address.slice(0, 12) }) |
| } finally { |
| setFiring(null) |
| } |
| }, [firing, fired, toast]) |
|
|
| const bulkRescue = useCallback(async () => { |
| if (bulkFiring || criticalUnfired.length === 0) return |
| setBulkFiring(true) |
| setBulkProgress({ done: 0, total: criticalUnfired.length }) |
|
|
| const targets = criticalUnfired.map(w => ({ |
| wallet: w.address, |
| eventName: EVENT_MAP[w.churnRisk], |
| risk: w.churnRisk, |
| score: w.riskScore, |
| })) |
|
|
| try { |
| const res = await fetch('/api/torque/bulk-fire', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ targets }), |
| }) |
| const data = await res.json() |
|
|
| if (data.details) { |
| const newFired = new Map(fired) |
| data.details.forEach((d: any, i: number) => { |
| if (d.success) newFired.set(criticalUnfired[i].address, d.eventId || 'sent') |
| }) |
| setFired(newFired) |
| setBulkProgress({ done: data.fired, total: data.total }) |
| toast({ |
| type: data.fired > 0 ? 'success' : 'error', |
| title: `Bulk rescue: ${data.fired}/${data.total} fired`, |
| body: data.fired > 0 ? 'All critical wallets targeted via Torque' : data.details[0]?.error, |
| }) |
| } |
| } catch (e: any) { |
| toast({ type: 'error', title: 'Bulk fire failed', body: e.message }) |
| } finally { |
| setBulkFiring(false) |
| setTimeout(() => setBulkProgress(null), 3000) |
| } |
| }, [bulkFiring, criticalUnfired, fired, toast]) |
|
|
| return ( |
| <div className="p-6 space-y-6"> |
| {recoveryWallet && ( |
| <RecoveryCard |
| wallet={recoveryWallet.address} |
| savedCount={recoveryWallet.savedCount} |
| daysInactive={parseInt(recoveryWallet.lastActive.match(/(\d+)/)?.[1] || '8')} |
| campaignName={recoveryWallet.churnRisk === 'critical' || recoveryWallet.churnRisk === 'high' ? 'Anti-Churn Gift Drop' : 'Streak Multiplier Rebate'} |
| onClose={() => setRecoveryWallet(null)} |
| /> |
| )} |
| |
| <div className="flex items-center justify-between"> |
| <div> |
| <h1 className="text-display-sm text-[#eaecef]">Wallets</h1> |
| <p className="text-body-md text-muted mt-1">Monitor wallet health, churn risk & activity patterns</p> |
| </div> |
| <div className="flex items-center gap-3"> |
| {preChurnCount > 0 && ( |
| <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-yellow/10 border border-brand-yellow/30"> |
| <AlertTriangle className="w-4 h-4 text-brand-yellow" /> |
| <span className="text-caption text-brand-yellow font-semibold">{preChurnCount} pre-churn</span> |
| </div> |
| )} |
| {fired.size > 0 && ( |
| <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-trading-up/10 border border-trading-up/30"> |
| <CheckCircle2 className="w-4 h-4 text-trading-up" /> |
| <span className="text-caption text-trading-up font-semibold">{fired.size} events sent</span> |
| </div> |
| )} |
| {criticalUnfired.length > 0 && ( |
| <button |
| onClick={bulkRescue} |
| disabled={bulkFiring} |
| className="flex items-center gap-2 px-4 py-2 rounded-md bg-trading-down/10 border border-trading-down/40 text-trading-down text-button font-semibold hover:bg-trading-down/20 transition disabled:opacity-50" |
| > |
| {bulkFiring ? <Loader2 className="w-4 h-4 animate-spin" /> : <Siren className="w-4 h-4" />} |
| {bulkProgress |
| ? `${bulkProgress.done}/${bulkProgress.total} fired` |
| : bulkFiring ? 'Rescuing...' |
| : `Rescue All Critical (${criticalUnfired.length})`} |
| </button> |
| )} |
| </div> |
| </div> |
| |
| <LiveAnalyzer /> |
| |
| <div className="grid grid-cols-2 lg:grid-cols-6 gap-3"> |
| {filters.map(r => ( |
| <button key={r} onClick={() => setF(r)} className={cn('rounded-xl border p-3 text-center transition-all', f === r ? 'border-brand-yellow/40 bg-brand-yellow/5' : 'border-hairline-dark bg-surface-card hover:border-brand-yellow/20')}> |
| <span className="text-caption text-muted capitalize">{r}</span> |
| <p className="font-mono text-title-md text-[#eaecef] tabular-nums mt-0.5">{r === 'all' ? wallets.length : wallets.filter(w => w.churnRisk === r).length}</p> |
| </button> |
| ))} |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| <div className="relative flex-1 max-w-md"> |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" /> |
| <input type="text" placeholder="Search wallet..." value={q} onChange={e => setQ(e.target.value)} className="w-full h-9 pl-9 pr-4 rounded-lg bg-surface-card border border-hairline-dark text-body-sm placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-brand-yellow/50" /> |
| </div> |
| <div className="flex items-center gap-1 p-1 rounded-lg bg-surface-card border border-hairline-dark"> |
| {(['risk', 'volume', 'streak'] as const).map(s => <button key={s} onClick={() => setSort(s)} className={cn('px-3 py-1.5 rounded-md text-caption transition capitalize', sort === s ? 'bg-brand-yellow text-ink font-semibold' : 'text-muted hover:text-[#eaecef]')}>{s}</button>)} |
| </div> |
| </div> |
| |
| <div className="rounded-xl bg-surface-card border border-hairline-dark overflow-hidden"> |
| <table className="w-full"><thead><tr className="border-b border-hairline-dark bg-surface-elevated/50"> |
| <th className="text-left text-caption text-muted uppercase tracking-wider px-5 py-3">Wallet</th> |
| <th className="text-center text-caption text-muted uppercase tracking-wider px-5 py-3">Risk</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Score</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Volume</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Streak</th> |
| <th className="text-center text-caption text-muted uppercase tracking-wider px-5 py-3">Protocols</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Last Active</th> |
| <th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Action</th> |
| </tr></thead><tbody>{list.map(w => { |
| const isFiring = firing === w.address |
| const eventId = fired.get(w.address) |
| const canIntervene = RISK_CAN_INTERVENE.includes(w.churnRisk) |
| const preChurn = isPreChurnWarning(w) |
| const rescued = RESCUED_WALLETS.has(w.address) |
| const wtype = classifyWalletType(w.protocols) |
| return ( |
| <tr key={w.address} className={cn('border-b border-hairline-dark/50 hover:bg-surface-hover transition-colors group', preChurn && 'bg-brand-yellow/3')}> |
| <td className="px-5 py-4"> |
| <div className="flex items-center gap-2"> |
| <span className="font-mono text-body-md text-[#eaecef]">{shortAddr(w.address)}</span> |
| <a href={`https://solscan.io/account/${w.address}`} target="_blank" rel="noreferrer"> |
| <ExternalLink className="w-3 h-3 text-muted opacity-0 group-hover:opacity-100 transition" /> |
| </a> |
| {preChurn && <span className="text-[9px] font-bold uppercase px-1.5 py-0.5 rounded bg-brand-yellow/15 text-brand-yellow border border-brand-yellow/30">⚡ pre-churn</span>} |
| </div> |
| <div className="flex items-center gap-2 mt-0.5"> |
| <span className="text-[10px] text-muted capitalize">{wtype}</span> |
| {eventId && eventId !== 'error' && ( |
| <p className="font-mono text-[10px] text-trading-up">✓ {eventId.slice(0, 10)}...</p> |
| )} |
| </div> |
| </td> |
| <td className="px-5 py-4 text-center"><RiskBadge risk={w.churnRisk} /></td> |
| <td className="px-5 py-4 text-right"> |
| <div className="flex items-center justify-end gap-2"> |
| <div className="w-16 h-1.5 rounded-full bg-surface-elevated overflow-hidden"> |
| <div className={cn('h-full rounded-full transition-all duration-700', w.riskScore >= 80 ? 'bg-trading-down' : w.riskScore >= 60 ? 'bg-[#ff9500]' : w.riskScore >= 40 ? 'bg-brand-yellow' : w.riskScore >= 20 ? 'bg-trading-up' : 'bg-brand-turquoise')} style={{ width: w.riskScore + '%' }} /> |
| </div> |
| <span className="font-mono text-num-sm text-muted tabular-nums w-8 text-right">{w.riskScore}</span> |
| </div> |
| </td> |
| <td className="px-5 py-4 text-right font-mono text-num-sm text-[#eaecef] tabular-nums">{fmtUsd(w.totalVolume)}</td> |
| <td className="px-5 py-4 text-right"> |
| <div className="flex items-center justify-end gap-1"> |
| <Flame className={cn('w-3.5 h-3.5', w.streak >= 30 ? 'text-brand-yellow' : w.streak >= 7 ? 'text-trading-up' : w.streak === 0 ? 'text-trading-down' : 'text-muted')} /> |
| <span className="font-mono text-num-sm tabular-nums">{w.streak}d</span> |
| </div> |
| </td> |
| <td className="px-5 py-4"> |
| <div className="flex flex-wrap justify-center gap-1"> |
| {w.protocols.slice(0, 3).map(p => <span key={p} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">{p}</span>)} |
| {w.protocols.length > 3 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">+{w.protocols.length - 3}</span>} |
| </div> |
| </td> |
| <td className="px-5 py-4 text-right text-body-sm text-muted">{w.lastActive}</td> |
| <td className="px-5 py-4 text-right"> |
| <div className="flex items-center justify-end gap-1.5"> |
| {rescued && ( |
| <button |
| onClick={() => setRecoveryWallet(w)} |
| className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-trading-up/10 border border-trading-up/30 text-trading-up text-[10px] font-semibold hover:bg-trading-up/20 transition" |
| title="Share recovery card" |
| > |
| <Share2 className="w-3 h-3" /> |
| </button> |
| )} |
| {canIntervene && ( |
| eventId ? ( |
| <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-trading-up"> |
| <CheckCircle2 className="w-3 h-3" />Sent |
| </span> |
| ) : ( |
| <button |
| onClick={() => intervene(w)} |
| disabled={!!firing} |
| className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-brand-yellow/10 border border-brand-yellow/30 text-brand-yellow text-[11px] font-semibold hover:bg-brand-yellow/20 transition disabled:opacity-40" |
| > |
| {isFiring ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />} |
| {isFiring ? 'Firing...' : ACTION_LABEL[w.churnRisk]} |
| </button> |
| ) |
| )} |
| {!canIntervene && !rescued && <ChevronRight className="w-4 h-4 text-muted opacity-0 group-hover:opacity-100 transition ml-auto" />} |
| </div> |
| </td> |
| </tr> |
| ) |
| })}</tbody></table> |
| </div> |
| </div> |
| ) |
| } |
|
|