| 'use client' |
| import { useState, useEffect, useCallback } from 'react' |
| import { Sliders, RotateCcw, Save, CheckCircle2, Users, AlertTriangle } from 'lucide-react' |
| import { cn } from '@/lib/utils' |
| import { wallets } from '@/lib/mock-data' |
| import { calculateChurnScore, DEFAULT_THRESHOLDS, ScoringThresholds } from '@/lib/agent-engine' |
| import type { ChurnRisk } from '@/lib/types' |
|
|
| const STORAGE_KEY = 'flowstate_thresholds' |
|
|
| function loadThresholds(): ScoringThresholds { |
| if (typeof window === 'undefined') return DEFAULT_THRESHOLDS |
| try { |
| const raw = localStorage.getItem(STORAGE_KEY) |
| return raw ? { ...DEFAULT_THRESHOLDS, ...JSON.parse(raw) } : DEFAULT_THRESHOLDS |
| } catch { |
| return DEFAULT_THRESHOLDS |
| } |
| } |
|
|
| function walletToSignals(w: typeof wallets[0]) { |
| const daysMatch = w.lastActive.match(/(\d+)d/) |
| const daysInactive = daysMatch ? parseInt(daysMatch[1]) : 0 |
| return { |
| daysInactive, |
| volumeDropPct: w.streak === 0 ? 80 : Math.max(0, (10 - Math.min(w.streak, 10)) * 8), |
| uniqueProtocols: w.protocols.length, |
| currentStreak: w.streak, |
| hasLiquidation: false, |
| } |
| } |
|
|
| const RISK_COLORS: Record<ChurnRisk, string> = { |
| critical: 'bg-trading-down/20 text-trading-down', |
| high: 'bg-[#ff9500]/20 text-[#ff9500]', |
| medium: 'bg-brand-yellow/20 text-brand-yellow', |
| low: 'bg-trading-up/20 text-trading-up', |
| safe: 'bg-muted/20 text-muted', |
| } |
|
|
| interface SliderRowProps { |
| label: string; desc: string; value: number; min: number; max: number; unit: string |
| onChange: (v: number) => void |
| } |
| function SliderRow({ label, desc, value, min, max, unit, onChange }: SliderRowProps) { |
| return ( |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <p className="text-body-sm font-medium text-[#eaecef]">{label}</p> |
| <p className="text-caption text-muted">{desc}</p> |
| </div> |
| <span className="font-mono text-num-sm text-brand-yellow tabular-nums w-16 text-right">{value}{unit}</span> |
| </div> |
| <input |
| type="range" min={min} max={max} value={value} |
| onChange={e => onChange(parseInt(e.target.value))} |
| className="w-full h-1.5 rounded-full appearance-none bg-surface-elevated cursor-pointer accent-brand-yellow" |
| /> |
| <div className="flex justify-between text-[10px] text-muted"> |
| <span>{min}{unit}</span><span>{max}{unit}</span> |
| </div> |
| </div> |
| ) |
| } |
|
|
| export default function SettingsPage() { |
| const [thresholds, setThresholds] = useState<ScoringThresholds>(DEFAULT_THRESHOLDS) |
| const [saved, setSaved] = useState(false) |
|
|
| useEffect(() => { setThresholds(loadThresholds()) }, []) |
|
|
| const set = useCallback(<K extends keyof ScoringThresholds>(key: K, value: number) => { |
| setThresholds(t => ({ ...t, [key]: value })) |
| }, []) |
|
|
| const preview = wallets.map(w => { |
| const signals = walletToSignals(w) |
| const { score, risk } = calculateChurnScore(signals, thresholds) |
| return { ...w, score, risk } |
| }) |
|
|
| const riskCounts = preview.reduce<Record<string, number>>((acc, w) => { |
| acc[w.risk] = (acc[w.risk] || 0) + 1 |
| return acc |
| }, {}) |
|
|
| const handleSave = () => { |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(thresholds)) |
| setSaved(true) |
| setTimeout(() => setSaved(false), 2000) |
| } |
|
|
| const handleReset = () => { |
| setThresholds(DEFAULT_THRESHOLDS) |
| localStorage.removeItem(STORAGE_KEY) |
| } |
|
|
| return ( |
| <div className="p-6 space-y-6 max-w-4xl"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <h1 className="text-display-sm text-[#eaecef]">Threshold Editor</h1> |
| <p className="text-body-md text-muted mt-1">Tune the 5-signal AI scoring model β live preview updates wallet risk tiers</p> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button onClick={handleReset} className="flex items-center gap-2 px-3 py-2 rounded-md border border-hairline-dark text-muted hover:text-[#eaecef] text-button transition"> |
| <RotateCcw className="w-3.5 h-3.5" />Reset |
| </button> |
| <button onClick={handleSave} 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"> |
| {saved ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Save className="w-3.5 h-3.5" />} |
| {saved ? 'Saved!' : 'Save Thresholds'} |
| </button> |
| </div> |
| </div> |
| |
| {/* Live preview */} |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5"> |
| <div className="flex items-center gap-2 mb-4"> |
| <Users className="w-4 h-4 text-brand-yellow" /> |
| <h3 className="text-title-sm">Live Preview β Wallet Distribution</h3> |
| <span className="text-caption text-muted ml-1">updates as you drag</span> |
| </div> |
| <div className="flex gap-3 flex-wrap"> |
| {(['critical','high','medium','low','safe'] as ChurnRisk[]).map(r => ( |
| <div key={r} className={cn('flex-1 min-w-[80px] rounded-xl border p-4 text-center', r === 'critical' ? 'border-trading-down/30' : r === 'high' ? 'border-[#ff9500]/30' : r === 'medium' ? 'border-brand-yellow/30' : r === 'low' ? 'border-trading-up/30' : 'border-hairline-dark')}> |
| <p className="text-caption text-muted capitalize mb-1">{r}</p> |
| <p className="font-mono text-title-lg text-[#eaecef] tabular-nums">{riskCounts[r] || 0}</p> |
| </div> |
| ))} |
| </div> |
| <div className="mt-4 space-y-1"> |
| {preview.map(w => ( |
| <div key={w.address} className="flex items-center gap-3 py-1.5 px-3 rounded-lg hover:bg-surface-elevated/50"> |
| <span className="font-mono text-caption text-muted w-36 truncate">{w.address.slice(0,8)}...{w.address.slice(-4)}</span> |
| <span className={cn('text-[10px] font-semibold uppercase px-2 py-0.5 rounded-pill', RISK_COLORS[w.risk as ChurnRisk])}>{w.risk}</span> |
| <div className="flex-1 h-1 rounded-full bg-surface-elevated overflow-hidden"> |
| <div className="h-full rounded-full bg-brand-yellow/60 transition-all duration-300" style={{width: w.score + '%'}} /> |
| </div> |
| <span className="font-mono text-num-sm text-muted tabular-nums w-8">{w.score}</span> |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
| {/* Inactivity thresholds */} |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6"> |
| <div className="flex items-center gap-2"> |
| <AlertTriangle className="w-4 h-4 text-trading-down" /> |
| <h3 className="text-title-sm">Inactivity Signals</h3> |
| </div> |
| <SliderRow label="Critical threshold" desc="Days inactive β critical risk signal" value={thresholds.inactivityCritical} min={7} max={60} unit="d" onChange={v => set('inactivityCritical', v)} /> |
| <SliderRow label="High threshold" desc="Days inactive β high risk signal" value={thresholds.inactivityHigh} min={3} max={30} unit="d" onChange={v => set('inactivityHigh', v)} /> |
| <SliderRow label="Medium threshold" desc="Days inactive β medium risk signal" value={thresholds.inactivityMedium} min={1} max={14} unit="d" onChange={v => set('inactivityMedium', v)} /> |
| </div> |
| |
| {/* Volume drop thresholds */} |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6"> |
| <div className="flex items-center gap-2"> |
| <Sliders className="w-4 h-4 text-brand-yellow" /> |
| <h3 className="text-title-sm">Volume Drop Signals</h3> |
| </div> |
| <SliderRow label="Critical drop" desc="Volume decline % β critical signal" value={thresholds.volumeDropCritical} min={50} max={100} unit="%" onChange={v => set('volumeDropCritical', v)} /> |
| <SliderRow label="High drop" desc="Volume decline % β high signal" value={thresholds.volumeDropHigh} min={25} max={90} unit="%" onChange={v => set('volumeDropHigh', v)} /> |
| <SliderRow label="Medium drop" desc="Volume decline % β medium signal" value={thresholds.volumeDropMedium} min={10} max={60} unit="%" onChange={v => set('volumeDropMedium', v)} /> |
| </div> |
| |
| {/* Pre-churn early warning */} |
| <div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6 lg:col-span-2"> |
| <div> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm">β‘</span> |
| <h3 className="text-title-sm">Pre-Churn Early Warning</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">3Γ more effective than win-back</span> |
| </div> |
| <p className="text-caption text-muted mt-1">Fire <code className="text-brand-yellow/80">inactivity_detected</code> before wallets reach churn threshold. Different sensitivity per wallet type.</p> |
| </div> |
| <div className="grid grid-cols-3 gap-6"> |
| <SliderRow label="Trader" desc="High-frequency DEX users" value={thresholds.preChurnDaysTrader} min={1} max={7} unit="d" onChange={v => set('preChurnDaysTrader', v)} /> |
| <SliderRow label="LP / Lender" desc="Kamino, Marginfi users" value={thresholds.preChurnDaysLP} min={2} max={10} unit="d" onChange={v => set('preChurnDaysLP', v)} /> |
| <SliderRow label="Staker" desc="Low-frequency holders" value={thresholds.preChurnDaysStaker} min={3} max={14} unit="d" onChange={v => set('preChurnDaysStaker', v)} /> |
| </div> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|