mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat(crypto): add SurfSense 2.0 Crypto Co-Pilot UI components
Frontend - Web Dashboard: - Add crypto dashboard page with Watchlist, Alerts, Market, Profile tabs - Add 11 tool-ui components for inline chat display - Add crypto components (ChainIcon, SafetyBadge, PriceDisplay, etc.) - Add modals (AddTokenModal, CreateAlertModal) - Add mock data for development Frontend - Browser Extension: - Add shared components (ChainIcon, RiskBadge, PriceDisplay, SuggestionCard) - Add crypto components (SafetyScoreDisplay, WatchlistPanel, AlertConfigModal) - Add chat enhancements (WelcomeScreen, ThinkingStepsDisplay) - Add widget components for inline display - Enhance TokenInfoCard, ChatHeader, ChatInput, ChatInterface Documentation: - Add conversational UX specification - Add UX analysis report - Update extension UX design This implements the Conversational UX paradigm where crypto features are AI-callable tools that render inline in the chat interface.
This commit is contained in:
parent
ad795eb830
commit
e4d020799b
58 changed files with 11315 additions and 661 deletions
|
|
@ -0,0 +1,770 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, Star, Bell, ExternalLink, Trash2, Plus, Activity, Zap, CheckCircle, Eye, Settings, Edit2, Percent, DollarSign, X, Flame, Fish, ArrowUpRight, ArrowDownRight, Globe, Wallet, User as UserIcon, Target } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
import { SafetyBadge } from "@/components/crypto/SafetyBadge";
|
||||
|
||||
// ============ MOCK DATA ============
|
||||
const MOCK_TOKEN_ANALYSIS = {
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "BULLAxK9xGJxGqPwPqTbGpLd9yKthvfNUet9V8wj8rWD",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
marketCap: 2100000,
|
||||
volume24h: 1200000,
|
||||
liquidity: 450000,
|
||||
safetyScore: 72,
|
||||
holderCount: 12500,
|
||||
top10HolderPercent: 45,
|
||||
};
|
||||
|
||||
const MOCK_WATCHLIST = [
|
||||
{ id: "1", symbol: "BULLA", name: "Bulla Token", chain: "solana", price: 0.00001234, priceChange24h: 156.7, alertCount: 2 },
|
||||
{ id: "2", symbol: "SOL", name: "Solana", chain: "solana", price: 98.45, priceChange24h: 3.2, alertCount: 1 },
|
||||
{ id: "3", symbol: "BONK", name: "Bonk", chain: "solana", price: 0.00002156, priceChange24h: -12.5, alertCount: 0 },
|
||||
{ id: "4", symbol: "WIF", name: "dogwifhat", chain: "solana", price: 2.34, priceChange24h: -5.8, alertCount: 3 },
|
||||
];
|
||||
|
||||
const MOCK_ALERTS = [
|
||||
{ id: "1", type: "price_above" as const, value: 0.00002, enabled: true },
|
||||
{ id: "2", type: "price_below" as const, value: 0.000008, enabled: true },
|
||||
{ id: "3", type: "percent_change" as const, value: 20, enabled: false },
|
||||
{ id: "4", type: "whale_activity" as const, value: 50000, enabled: true },
|
||||
];
|
||||
|
||||
const MOCK_PROACTIVE_ALERT = {
|
||||
alertType: "price_surge" as const,
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
value: 0.00001234,
|
||||
previousValue: 0.00000482,
|
||||
message: "BULLA just surged 156% in the last hour! This is unusual activity - consider taking profits or setting a stop-loss.",
|
||||
severity: "warning" as const,
|
||||
timestamp: "2 min ago",
|
||||
};
|
||||
|
||||
const MOCK_ACTION_CONFIRMATION = {
|
||||
actionType: "watchlist_add" as const,
|
||||
tokenSymbol: "BULLA",
|
||||
details: ["Price alerts (±10%)", "Whale activity monitoring", "Safety score changes"],
|
||||
};
|
||||
|
||||
// ============ HELPER FUNCTIONS ============
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// ============ DEMO COMPONENTS ============
|
||||
|
||||
// 1. Token Analysis Demo
|
||||
function TokenAnalysisDemo() {
|
||||
const args = MOCK_TOKEN_ANALYSIS;
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
Token Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{args.symbol}</span>
|
||||
<span className="text-muted-foreground text-sm">{args.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatPrice(args.price)}</span>
|
||||
<span className={cn("flex items-center gap-0.5 text-sm font-medium", args.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{args.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{args.priceChange24h >= 0 ? "+" : ""}{args.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SafetyBadge score={args.safetyScore} size="lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.marketCap)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.volume24h)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.liquidity)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Holders</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{args.holderCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{args.top10HolderPercent > 50 && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Top 10 holders own {args.top10HolderPercent}% of supply - high concentration risk</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
<Star className="h-4 w-4 mr-2" />Add to Watchlist
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><Bell className="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="sm"><ExternalLink className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Watchlist Display Demo
|
||||
function WatchlistDisplayDemo() {
|
||||
const tokens = MOCK_WATCHLIST;
|
||||
const sortedByChange = [...tokens].sort((a, b) => b.priceChange24h - a.priceChange24h);
|
||||
const bestPerformer = sortedByChange[0];
|
||||
const worstPerformer = sortedByChange[sortedByChange.length - 1];
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
Your Watchlist
|
||||
<Badge variant="secondary">{tokens.length}</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm"><Plus className="h-4 w-4 mr-1" />Add Token</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
{token.alertCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
<Bell className="h-2.5 w-2.5 mr-0.5" />{token.alertCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm flex items-center justify-end gap-0.5", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 border-t text-sm text-muted-foreground">
|
||||
<span className="text-green-500 font-medium">{bestPerformer.symbol}</span> is your best performer (+{bestPerformer.priceChange24h.toFixed(1)}%)
|
||||
{worstPerformer.priceChange24h < 0 && (
|
||||
<span> • <span className="text-red-500 font-medium">{worstPerformer.symbol}</span> needs attention ({worstPerformer.priceChange24h.toFixed(1)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Action Confirmation Demo
|
||||
function ActionConfirmationDemo() {
|
||||
const args = MOCK_ACTION_CONFIRMATION;
|
||||
return (
|
||||
<Card className="overflow-hidden border-l-4 border-l-green-500">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-yellow-500/10">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span className="font-medium">Added to Watchlist</span>
|
||||
<Badge variant="secondary" className="font-mono">{args.tokenSymbol}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mb-1">Default monitoring enabled:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{args.details.map((detail, i) => (<li key={i}>{detail}</li>))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4 ml-11">
|
||||
<Button variant="outline" size="sm"><Eye className="h-3 w-3 mr-1" />View Watchlist</Button>
|
||||
<Button variant="outline" size="sm"><Settings className="h-3 w-3 mr-1" />Edit Alerts</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Alert Configuration Demo
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_above: { icon: TrendingUp, label: "Price Above", color: "text-green-500" },
|
||||
price_below: { icon: TrendingDown, label: "Price Below", color: "text-red-500" },
|
||||
percent_change: { icon: Percent, label: "% Change", color: "text-blue-500" },
|
||||
volume_spike: { icon: Activity, label: "Volume Spike", color: "text-purple-500" },
|
||||
whale_activity: { icon: DollarSign, label: "Whale Activity", color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const formatAlertValue = (type: string, value: number): string => {
|
||||
if (type === "percent_change") return `${value > 0 ? "+" : ""}${value}%`;
|
||||
if (type === "volume_spike") return `${value}x normal`;
|
||||
if (type === "whale_activity") return `>${value.toLocaleString()} USD`;
|
||||
return `$${value < 1 ? value.toFixed(6) : value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
function AlertConfigurationDemo() {
|
||||
const alerts = MOCK_ALERTS;
|
||||
const enabledCount = alerts.filter(a => a.enabled).length;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-blue-500" />
|
||||
Alerts for BULLA
|
||||
<Badge variant="secondary">{enabledCount} active</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm"><Bell className="h-4 w-4 mr-1" />Add Alert</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={alert.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<div>
|
||||
<p className="font-medium">{config.label}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatAlertValue(alert.type, alert.value)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={alert.enabled} />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><Edit2 className="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500"><Trash2 className="h-3 w-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Proactive Alert Demo
|
||||
function ProactiveAlertDemo() {
|
||||
const args = MOCK_PROACTIVE_ALERT;
|
||||
const change = ((args.value - args.previousValue) / args.previousValue) * 100;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-l-4 border-l-green-500">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-green-500/10">
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase text-xs">PRICE SURGE</Badge>
|
||||
<span className="font-bold">{args.tokenSymbol}</span>
|
||||
<span className="font-medium text-green-500">+{change.toFixed(1)}%</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{args.timestamp}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{args.message}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground"><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3 ml-11">
|
||||
<Button variant="outline" size="sm"><Eye className="h-3 w-3 mr-1" />View Details</Button>
|
||||
<Button variant="outline" size="sm"><Bell className="h-3 w-3 mr-1" />Adjust Alert</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Trending Tokens Demo
|
||||
const MOCK_TRENDING = [
|
||||
{ symbol: "BULLA", name: "Bulla Token", chain: "solana", price: 0.00001234, priceChange24h: 156.7, volume24h: 1200000, rank: 1 },
|
||||
{ symbol: "POPCAT", name: "Popcat", chain: "solana", price: 0.89, priceChange24h: 45.2, volume24h: 8500000, rank: 2 },
|
||||
{ symbol: "WIF", name: "dogwifhat", chain: "solana", price: 2.34, priceChange24h: 32.1, volume24h: 15000000, rank: 3 },
|
||||
{ symbol: "BONK", name: "Bonk", chain: "solana", price: 0.00002156, priceChange24h: 18.5, volume24h: 5200000, rank: 4 },
|
||||
];
|
||||
|
||||
function TrendingTokensDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
Trending on Solana
|
||||
<Badge variant="secondary">24h</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{MOCK_TRENDING.map((token) => (
|
||||
<div key={token.symbol} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted-foreground w-6">#{token.rank}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
+{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right hidden md:block">
|
||||
<p className="text-xs text-muted-foreground">Volume</p>
|
||||
<p className="text-sm">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><Star className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Whale Activity Demo
|
||||
const MOCK_WHALE_TXS = [
|
||||
{ id: "1", type: "buy" as const, amountUsd: 250000, walletLabel: "Smart Money 1", timestamp: "5m ago" },
|
||||
{ id: "2", type: "sell" as const, amountUsd: 180000, walletLabel: "Whale #42", timestamp: "12m ago" },
|
||||
{ id: "3", type: "buy" as const, amountUsd: 320000, walletLabel: null, timestamp: "25m ago" },
|
||||
{ id: "4", type: "transfer" as const, amountUsd: 500000, walletLabel: "Exchange Hot Wallet", timestamp: "1h ago" },
|
||||
];
|
||||
|
||||
function WhaleActivityDemo() {
|
||||
const summary = { totalBuyVolume: 570000, totalSellVolume: 180000, netFlow: 390000, uniqueWhales: 4 };
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="h-5 w-5 text-blue-500" />
|
||||
Whale Activity - BULLA
|
||||
</div>
|
||||
<ChainIcon chain="solana" size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Buy Volume</p>
|
||||
<p className="font-medium text-green-500">{formatLargeNumber(summary.totalBuyVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Sell Volume</p>
|
||||
<p className="font-medium text-red-500">{formatLargeNumber(summary.totalSellVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Net Flow</p>
|
||||
<p className="font-medium text-green-500">+{formatLargeNumber(summary.netFlow)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Unique Whales</p>
|
||||
<p className="font-medium">{summary.uniqueWhales}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_WHALE_TXS.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-full", tx.type === "buy" ? "bg-green-500/10" : tx.type === "sell" ? "bg-red-500/10" : "bg-muted")}>
|
||||
{tx.type === "buy" ? <ArrowUpRight className="h-4 w-4 text-green-500" /> : tx.type === "sell" ? <ArrowDownRight className="h-4 w-4 text-red-500" /> : <ArrowUpRight className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className={cn("font-medium capitalize", tx.type === "buy" ? "text-green-500" : tx.type === "sell" ? "text-red-500" : "")}>{tx.type}</span>
|
||||
<span className="font-medium ml-2">{formatLargeNumber(tx.amountUsd)}</span>
|
||||
<p className="text-xs text-muted-foreground">{tx.walletLabel || "Unknown Wallet"} • {tx.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Market Overview Demo
|
||||
const MOCK_MARKET = [
|
||||
{ symbol: "BTC", name: "Bitcoin", price: 67500, priceChange24h: 2.3 },
|
||||
{ symbol: "ETH", name: "Ethereum", price: 3450, priceChange24h: -1.2 },
|
||||
{ symbol: "SOL", name: "Solana", price: 98.45, priceChange24h: 5.7 },
|
||||
];
|
||||
|
||||
function MarketOverviewDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
Market Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium">$2.45T</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">$89.2B</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium">52.3%</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Fear & Greed</p>
|
||||
<p className="font-medium text-green-500">72 - Greed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{MOCK_MARKET.map((token) => (
|
||||
<div key={token.symbol} className="bg-muted/50 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-lg">{token.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${token.price.toLocaleString()}</p>
|
||||
<p className={cn("text-sm", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 9. Holder Analysis Demo
|
||||
const MOCK_HOLDERS = [
|
||||
{ rank: 1, address: "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", label: "Raydium LP", percentage: 15.2, balance: 152000000 },
|
||||
{ rank: 2, address: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", label: null, percentage: 8.5, balance: 85000000 },
|
||||
{ rank: 3, address: "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH", label: "Team Wallet", percentage: 7.2, balance: 72000000 },
|
||||
];
|
||||
|
||||
function HolderAnalysisDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
Holder Analysis - BULLA
|
||||
</div>
|
||||
<ChainIcon chain="solana" size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium">12,500</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
|
||||
<p className="font-medium text-red-500">45.2%</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium">68.5%</p>
|
||||
</div>
|
||||
<div className="bg-yellow-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium text-yellow-500">Medium</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_HOLDERS.map((holder) => (
|
||||
<div key={holder.rank} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-6">#{holder.rank}</span>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{holder.label || `${holder.address.slice(0, 6)}...${holder.address.slice(-4)}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-xs text-muted-foreground">{(holder.balance / 1e6).toFixed(1)}M</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Portfolio Display Demo
|
||||
const MOCK_PORTFOLIO_HOLDINGS = [
|
||||
{ symbol: "SOL", name: "Solana", chain: "solana", balance: 50, value: 4922.5, pnlPercent: 125.5, allocation: 45 },
|
||||
{ symbol: "BULLA", name: "Bulla Token", chain: "solana", balance: 5000000, value: 61.7, pnlPercent: 256.7, allocation: 5.6 },
|
||||
{ symbol: "ETH", name: "Ethereum", chain: "ethereum", balance: 1.5, value: 5175, pnlPercent: 45.2, allocation: 47.3 },
|
||||
];
|
||||
|
||||
function PortfolioDisplayDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-emerald-500" />
|
||||
Your Portfolio
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-gradient-to-r from-emerald-500/10 to-blue-500/10 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Value</p>
|
||||
<p className="text-3xl font-bold">$10,934.20</p>
|
||||
<p className="text-sm text-green-500 flex items-center gap-1 mt-1">
|
||||
<TrendingUp className="h-4 w-4" />+$3,245.80 (+42.3%)
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_PORTFOLIO_HOLDINGS.map((holding) => (
|
||||
<div key={holding.symbol} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={holding.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{holding.symbol}</span>
|
||||
<Badge variant="secondary" className="text-xs">{holding.allocation.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{holding.balance.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${holding.value.toLocaleString()}</p>
|
||||
<p className={cn("text-sm", holding.pnlPercent >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
+{holding.pnlPercent.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 11. User Profile Demo
|
||||
function UserProfileDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserIcon className="h-5 w-5 text-indigo-500" />
|
||||
Your Investment Profile
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg p-4 border text-yellow-500 bg-yellow-500/10 border-yellow-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Risk Tolerance</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">Moderate</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Balance between risk and reward</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4 border bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Investment Style</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">Swing Trader</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold for days to weeks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Preferred Chains</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">Solana</Badge>
|
||||
<Badge variant="default">Ethereum</Badge>
|
||||
<Badge variant="outline">Base</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
Say "update my risk tolerance to aggressive" to change settings
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CryptoToolsDemoPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">🧪 Crypto Tools Demo</h1>
|
||||
<p className="text-muted-foreground">Preview of all crypto tool UI components with mock data</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">These components render inline in chat when AI calls the corresponding tools.</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">1</span>
|
||||
Token Analysis <code className="text-xs bg-muted px-2 py-1 rounded ml-2">analyze_token</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Analyze BULLA", "Is BULLA safe?", "Research this token"</p>
|
||||
<TokenAnalysisDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">2</span>
|
||||
Watchlist Display <code className="text-xs bg-muted px-2 py-1 rounded ml-2">show_watchlist</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my watchlist", "What tokens am I tracking?"</p>
|
||||
<WatchlistDisplayDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">3</span>
|
||||
Action Confirmation <code className="text-xs bg-muted px-2 py-1 rounded ml-2">confirm_action</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Add BULLA to watchlist", "Remove SOL from watchlist"</p>
|
||||
<ActionConfirmationDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">4</span>
|
||||
Alert Configuration <code className="text-xs bg-muted px-2 py-1 rounded ml-2">configure_alerts</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my alerts for BULLA", "Set alert if BULLA drops 20%"</p>
|
||||
<AlertConfigurationDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">5</span>
|
||||
Proactive Alert <code className="text-xs bg-muted px-2 py-1 rounded ml-2">proactive_alert</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">AI-initiated: Automatically sent when price surges, whale activity detected, etc.</p>
|
||||
<ProactiveAlertDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">6</span>
|
||||
Trending Tokens <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_trending_tokens</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "What's hot on Solana?", "Show trending tokens", "What's pumping today?"</p>
|
||||
<TrendingTokensDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">7</span>
|
||||
Whale Activity <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_whale_activity</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show whale activity for BULLA", "Any big buys?", "Who's accumulating?"</p>
|
||||
<WhaleActivityDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">8</span>
|
||||
Market Overview <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_market_overview</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "How's the market?", "Show market overview", "What's the sentiment?"</p>
|
||||
<MarketOverviewDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">9</span>
|
||||
Holder Analysis <code className="text-xs bg-muted px-2 py-1 rounded ml-2">analyze_holders</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Who holds BULLA?", "Show holder distribution", "Is it concentrated?"</p>
|
||||
<HolderAnalysisDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">10</span>
|
||||
Portfolio Display <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_portfolio</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "How's my portfolio?", "Show my holdings", "What's my P&L?"</p>
|
||||
<PortfolioDisplayDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">11</span>
|
||||
User Profile <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_user_profile</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my profile", "What's my risk tolerance?", "Update my investment style"</p>
|
||||
<UserProfileDemo />
|
||||
</section>
|
||||
</div>
|
||||
<div className="mt-12 p-4 bg-muted/50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">💡 How it works</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When you chat with the AI and ask crypto-related questions, the AI calls these tools and the corresponding UI components render inline in the chat.
|
||||
This creates a seamless conversational experience where data and actions are embedded directly in the conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||
<h3 className="font-semibold mb-2 text-green-600">✅ All 11 Tool-UI Components Complete</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Components 1-5 (blue) are the original tools. Components 6-11 (orange) are newly added to cover all crypto features in the spec.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
272
surfsense_web/app/dashboard/[search_space_id]/crypto/page.tsx
Normal file
272
surfsense_web/app/dashboard/[search_space_id]/crypto/page.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MessageSquare, Sparkles, Star, Bell, TrendingUp, ArrowRight, X, Plus, User } from "lucide-react";
|
||||
import {
|
||||
MarketOverview,
|
||||
WatchlistTable,
|
||||
AlertsPanel,
|
||||
PortfolioSummary,
|
||||
AddTokenModal,
|
||||
CreateAlertModal,
|
||||
UserProfileSection,
|
||||
type AlertConfig,
|
||||
type UserProfile,
|
||||
} from "@/components/crypto";
|
||||
import {
|
||||
MOCK_MARKET_PRICES,
|
||||
MOCK_WATCHLIST,
|
||||
MOCK_ALERTS,
|
||||
MOCK_PORTFOLIO,
|
||||
} from "@/lib/mock/cryptoMockData";
|
||||
|
||||
// Default user profile
|
||||
const DEFAULT_PROFILE: UserProfile = {
|
||||
riskTolerance: "moderate",
|
||||
investmentStyle: "swing",
|
||||
preferredChains: ["solana", "ethereum"],
|
||||
notifications: {
|
||||
priceAlerts: true,
|
||||
whaleAlerts: true,
|
||||
newsAlerts: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Crypto Dashboard Page
|
||||
*
|
||||
* Full-featured crypto management dashboard with:
|
||||
* - Market Overview
|
||||
* - Watchlist Management (with Add Token modal)
|
||||
* - Alerts Management (with Create Alert modal)
|
||||
* - Portfolio Summary
|
||||
* - User Profile Settings
|
||||
*
|
||||
* Also includes a banner promoting the AI Chat for research & analysis.
|
||||
*/
|
||||
export default function CryptoDashboardPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id;
|
||||
|
||||
// UI State
|
||||
const [showAIBanner, setShowAIBanner] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("watchlist");
|
||||
const [showAddTokenModal, setShowAddTokenModal] = useState(false);
|
||||
const [showCreateAlertModal, setShowCreateAlertModal] = useState(false);
|
||||
const [alertPrefilledToken, setAlertPrefilledToken] = useState<{ symbol: string; chain: string } | undefined>();
|
||||
|
||||
// Data State (mock - would be from API in production)
|
||||
const [watchlist, setWatchlist] = useState(MOCK_WATCHLIST);
|
||||
const [alerts, setAlerts] = useState(MOCK_ALERTS);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile>(DEFAULT_PROFILE);
|
||||
|
||||
const handleGoToChat = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
};
|
||||
|
||||
const handleTokenClick = (token: any) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat?query=Analyze ${token.symbol}`);
|
||||
};
|
||||
|
||||
const handleConfigureAlerts = (token: any) => {
|
||||
setAlertPrefilledToken({ symbol: token.symbol, chain: token.chain });
|
||||
setShowCreateAlertModal(true);
|
||||
};
|
||||
|
||||
const handleAlertClick = (alert: any) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat?query=Tell me about ${alert.tokenSymbol}`);
|
||||
};
|
||||
|
||||
const handleRemoveToken = (tokenId: string) => {
|
||||
setWatchlist((prev) => prev.filter((t) => t.id !== tokenId));
|
||||
};
|
||||
|
||||
const handleAddToken = (token: { symbol: string; name: string; chain: string; contractAddress?: string }) => {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
chain: token.chain,
|
||||
contractAddress: token.contractAddress,
|
||||
price: 0,
|
||||
priceChange24h: 0,
|
||||
safetyScore: undefined,
|
||||
alertCount: 0,
|
||||
};
|
||||
setWatchlist((prev) => [...prev, newToken]);
|
||||
};
|
||||
|
||||
const handleCreateAlert = (alertConfig: AlertConfig) => {
|
||||
const newAlert = {
|
||||
id: `alert-${Date.now()}`,
|
||||
tokenSymbol: alertConfig.tokenSymbol,
|
||||
chain: alertConfig.chain,
|
||||
type: alertConfig.alertType,
|
||||
message: `${alertConfig.alertType.replace("_", " ")} alert for ${alertConfig.tokenSymbol}`,
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
isRead: false,
|
||||
};
|
||||
setAlerts((prev) => [newAlert, ...prev]);
|
||||
};
|
||||
|
||||
const handleSaveProfile = (profile: UserProfile) => {
|
||||
setUserProfile(profile);
|
||||
// In production, save to backend
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* AI Chat Promotion Banner */}
|
||||
{showAIBanner && (
|
||||
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
💡 Try our AI Crypto Advisor for deeper analysis!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask questions like "Is BULLA safe?" or "Set alert if SOL drops 10%"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleGoToChat} className="gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Open AI Chat
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowAIBanner(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-4 md:p-6 max-w-7xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
🚀 Crypto Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Manage your watchlist, alerts, and track market trends
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="watchlist" className="gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Watchlist
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="alerts" className="gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Alerts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="market" className="gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Market
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Watchlist Tab */}
|
||||
<TabsContent value="watchlist" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowAddTokenModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<WatchlistTable
|
||||
tokens={watchlist}
|
||||
onTokenClick={handleTokenClick}
|
||||
onConfigureAlerts={handleConfigureAlerts}
|
||||
onRemoveToken={handleRemoveToken}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PortfolioSummary portfolio={MOCK_PORTFOLIO} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Alerts Tab */}
|
||||
<TabsContent value="alerts" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setAlertPrefilledToken(undefined); setShowCreateAlertModal(true); }} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Alert
|
||||
</Button>
|
||||
</div>
|
||||
<AlertsPanel
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Market Tab */}
|
||||
<TabsContent value="market" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<MarketOverview tokens={MOCK_MARKET_PRICES} />
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Sparkles className="h-12 w-12 text-primary/50 mb-4" />
|
||||
<h3 className="font-semibold mb-2">Want deeper market insights?</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ask our AI about trending tokens, market sentiment, or specific analysis
|
||||
</p>
|
||||
<Button onClick={handleGoToChat} className="gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Ask AI Advisor
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="max-w-xl">
|
||||
<UserProfileSection
|
||||
profile={userProfile}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddTokenModal
|
||||
open={showAddTokenModal}
|
||||
onOpenChange={setShowAddTokenModal}
|
||||
onAddToken={handleAddToken}
|
||||
/>
|
||||
<CreateAlertModal
|
||||
open={showCreateAlertModal}
|
||||
onOpenChange={setShowCreateAlertModal}
|
||||
onCreateAlert={handleCreateAlert}
|
||||
prefilledToken={alertPrefilledToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
surfsense_web/components/crypto/AddTokenModal.tsx
Normal file
150
surfsense_web/components/crypto/AddTokenModal.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface AddTokenModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddToken: (token: { symbol: string; name: string; chain: string; contractAddress?: string }) => void;
|
||||
}
|
||||
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "solana", label: "Solana" },
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "base", label: "Base" },
|
||||
{ value: "arbitrum", label: "Arbitrum" },
|
||||
{ value: "polygon", label: "Polygon" },
|
||||
];
|
||||
|
||||
export function AddTokenModal({ open, onOpenChange, onAddToken }: AddTokenModalProps) {
|
||||
const [symbol, setSymbol] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [chain, setChain] = useState("solana");
|
||||
const [contractAddress, setContractAddress] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!symbol.trim()) {
|
||||
setError("Token symbol is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chain) {
|
||||
setError("Please select a chain");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onAddToken({
|
||||
symbol: symbol.toUpperCase().trim(),
|
||||
name: name.trim() || symbol.toUpperCase().trim(),
|
||||
chain,
|
||||
contractAddress: contractAddress.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setSymbol("");
|
||||
setName("");
|
||||
setChain("solana");
|
||||
setContractAddress("");
|
||||
setIsLoading(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Add Token to Watchlist
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="symbol">Token Symbol *</Label>
|
||||
<Input
|
||||
id="symbol"
|
||||
placeholder="e.g., BULLA, SOL, ETH"
|
||||
value={symbol}
|
||||
onChange={(e) => setSymbol(e.target.value)}
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Token Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Bulla Token"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chain">Chain *</Label>
|
||||
<Select value={chain} onValueChange={setChain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CHAINS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="contract">Contract Address (optional)</Label>
|
||||
<Input
|
||||
id="contract"
|
||||
placeholder="0x... or token mint address"
|
||||
value={contractAddress}
|
||||
onChange={(e) => setContractAddress(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide contract address for accurate token identification
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add to Watchlist
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
171
surfsense_web/components/crypto/AlertsPanel.tsx
Normal file
171
surfsense_web/components/crypto/AlertsPanel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bell, BellOff, Check, AlertTriangle, Info, XCircle } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import type { Alert } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface AlertsPanelProps {
|
||||
alerts: Alert[];
|
||||
onAlertClick?: (alert: Alert) => void;
|
||||
onMarkAsRead?: (alertId: string) => void;
|
||||
onMarkAllAsRead?: () => void;
|
||||
onDismiss?: (alertId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function getSeverityConfig(severity: Alert["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-yellow-500",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Info,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/20",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function AlertItem({
|
||||
alert,
|
||||
onClick,
|
||||
onMarkAsRead,
|
||||
onDismiss,
|
||||
}: {
|
||||
alert: Alert;
|
||||
onClick?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const config = getSeverityConfig(alert.severity);
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
|
||||
config.bg,
|
||||
config.border,
|
||||
!alert.isRead && "ring-1 ring-primary/20",
|
||||
"hover:bg-muted/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={cn("mt-0.5", config.color)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ChainIcon chain={alert.chain} size="sm" />
|
||||
<span className="font-medium text-sm">{alert.tokenSymbol}</span>
|
||||
{!alert.isRead && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[10px]">NEW</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{alert.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{formatTimeAgo(alert.timestamp)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{!alert.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead?.();
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsPanel({
|
||||
alerts,
|
||||
onAlertClick,
|
||||
onMarkAsRead,
|
||||
onMarkAllAsRead,
|
||||
onDismiss,
|
||||
className,
|
||||
}: AlertsPanelProps) {
|
||||
const unreadCount = alerts.filter((a) => !a.isRead).length;
|
||||
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" /> Alerts
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1">{unreadCount}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={onMarkAllAsRead}>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<BellOff className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">No alerts yet</p>
|
||||
<p className="text-xs">Configure alerts on your watchlist tokens</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<AlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onClick={() => onAlertClick?.(alert)}
|
||||
onMarkAsRead={() => onMarkAsRead?.(alert.id)}
|
||||
onDismiss={() => onDismiss?.(alert.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
48
surfsense_web/components/crypto/ChainIcon.tsx
Normal file
48
surfsense_web/components/crypto/ChainIcon.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChainType } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface ChainIconProps {
|
||||
chain: ChainType;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showName?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const chainConfig: Record<ChainType, { color: string; icon: string; name: string }> = {
|
||||
solana: { color: "#9945FF", icon: "◎", name: "Solana" },
|
||||
ethereum: { color: "#627EEA", icon: "Ξ", name: "Ethereum" },
|
||||
base: { color: "#0052FF", icon: "🔵", name: "Base" },
|
||||
arbitrum: { color: "#28A0F0", icon: "🔷", name: "Arbitrum" },
|
||||
polygon: { color: "#8247E5", icon: "⬡", name: "Polygon" },
|
||||
bsc: { color: "#F0B90B", icon: "⬢", name: "BNB Chain" },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4 text-xs",
|
||||
md: "h-5 w-5 text-sm",
|
||||
lg: "h-6 w-6 text-base",
|
||||
};
|
||||
|
||||
export function ChainIcon({ chain, size = "md", showName = false, className }: ChainIconProps) {
|
||||
const config = chainConfig[chain] || { color: "#888888", icon: "?", name: chain };
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: `${config.color}20`, color: config.color }}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
{showName && (
|
||||
<span className="text-sm text-muted-foreground">{config.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
surfsense_web/components/crypto/CreateAlertModal.tsx
Normal file
166
surfsense_web/components/crypto/CreateAlertModal.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Bell, Loader2 } from "lucide-react";
|
||||
|
||||
interface CreateAlertModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateAlert: (alert: AlertConfig) => void;
|
||||
prefilledToken?: { symbol: string; chain: string };
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
tokenSymbol: string;
|
||||
chain: string;
|
||||
alertType: string;
|
||||
threshold?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ value: "price_above", label: "Price Above", hasThreshold: true, unit: "$" },
|
||||
{ value: "price_below", label: "Price Below", hasThreshold: true, unit: "$" },
|
||||
{ value: "price_change", label: "Price Change %", hasThreshold: true, unit: "%" },
|
||||
{ value: "volume_spike", label: "Volume Spike", hasThreshold: true, unit: "x" },
|
||||
{ value: "whale_buy", label: "Whale Buy", hasThreshold: false },
|
||||
{ value: "whale_sell", label: "Whale Sell", hasThreshold: false },
|
||||
];
|
||||
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "solana", label: "Solana" },
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "base", label: "Base" },
|
||||
];
|
||||
|
||||
export function CreateAlertModal({ open, onOpenChange, onCreateAlert, prefilledToken }: CreateAlertModalProps) {
|
||||
const [tokenSymbol, setTokenSymbol] = useState(prefilledToken?.symbol || "");
|
||||
const [chain, setChain] = useState(prefilledToken?.chain || "solana");
|
||||
const [alertType, setAlertType] = useState("price_above");
|
||||
const [threshold, setThreshold] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selectedAlertType = ALERT_TYPES.find((t) => t.value === alertType);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!tokenSymbol.trim()) {
|
||||
setError("Token symbol is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAlertType?.hasThreshold && !threshold) {
|
||||
setError("Threshold value is required for this alert type");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onCreateAlert({
|
||||
tokenSymbol: tokenSymbol.toUpperCase().trim(),
|
||||
chain,
|
||||
alertType,
|
||||
threshold: selectedAlertType?.hasThreshold ? parseFloat(threshold) : undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setTokenSymbol("");
|
||||
setAlertType("price_above");
|
||||
setThreshold("");
|
||||
setEnabled(true);
|
||||
setIsLoading(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Create Alert
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="token">Token Symbol *</Label>
|
||||
<Input
|
||||
id="token"
|
||||
placeholder="e.g., SOL"
|
||||
value={tokenSymbol}
|
||||
onChange={(e) => setTokenSymbol(e.target.value)}
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chain">Chain</Label>
|
||||
<Select value={chain} onValueChange={setChain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CHAINS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="alertType">Alert Type *</Label>
|
||||
<Select value={alertType} onValueChange={setAlertType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALERT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedAlertType?.hasThreshold && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="threshold">Threshold ({selectedAlertType.unit}) *</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder={`Enter value in ${selectedAlertType.unit}`}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enabled">Enable Alert</Label>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Creating...</> : <><Bell className="h-4 w-4 mr-2" />Create Alert</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
64
surfsense_web/components/crypto/MarketOverview.tsx
Normal file
64
surfsense_web/components/crypto/MarketOverview.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TokenPrice } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface MarketOverviewProps {
|
||||
tokens: TokenPrice[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MarketCard({ token }: { token: TokenPrice }) {
|
||||
const isPositive = token.priceChange24h > 0;
|
||||
const isNegative = token.priceChange24h < 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold">
|
||||
{token.icon || token.symbol.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{token.symbol}</div>
|
||||
<div className="text-xs text-muted-foreground">{token.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{formatPrice(token.price)}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-1 text-xs",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{formatPercent(token.priceChange24h)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketOverview({ tokens, className }: MarketOverviewProps) {
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span> Market Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tokens.map((token) => (
|
||||
<MarketCard key={token.symbol} token={token} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
133
surfsense_web/components/crypto/PortfolioSummary.tsx
Normal file
133
surfsense_web/components/crypto/PortfolioSummary.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Wallet, PieChart } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import type { PortfolioSummary as PortfolioSummaryType, PortfolioToken } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface PortfolioSummaryProps {
|
||||
portfolio: PortfolioSummaryType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
changePercent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
}) {
|
||||
const isPositive = change !== undefined && change > 0;
|
||||
const isNegative = change !== undefined && change < 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{change !== undefined && changePercent !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm mt-1",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
<span>{formatPrice(Math.abs(change))}</span>
|
||||
<span>({formatPercent(changePercent)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({ token }: { token: PortfolioToken }) {
|
||||
const isPositive = token.pnl > 0;
|
||||
const isNegative = token.pnl < 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium">{token.symbol}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{token.amount.toLocaleString()} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{formatPrice(token.value)}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500"
|
||||
)}
|
||||
>
|
||||
{formatPercent(token.pnlPercent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 text-right">
|
||||
<div className="text-sm text-muted-foreground">{token.allocation.toFixed(1)}%</div>
|
||||
<div className="h-1.5 w-full bg-muted rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${token.allocation}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioSummary({ portfolio, className }: PortfolioSummaryProps) {
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" /> Portfolio
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label="Total Value"
|
||||
value={formatPrice(portfolio.totalValue)}
|
||||
change={portfolio.change24h}
|
||||
changePercent={portfolio.change24hPercent}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total P&L"
|
||||
value={formatPrice(portfolio.totalPnl)}
|
||||
change={portfolio.totalPnl}
|
||||
changePercent={portfolio.totalPnlPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Holdings */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Holdings</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{portfolio.tokens.map((token) => (
|
||||
<TokenRow key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
58
surfsense_web/components/crypto/PriceDisplay.tsx
Normal file
58
surfsense_web/components/crypto/PriceDisplay.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { formatPrice, formatPercent } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
priceChange?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { price: "text-sm font-medium", change: "text-xs" },
|
||||
md: { price: "text-lg font-semibold", change: "text-sm" },
|
||||
lg: { price: "text-2xl font-bold", change: "text-base" },
|
||||
};
|
||||
|
||||
export function PriceDisplay({
|
||||
price,
|
||||
priceChange,
|
||||
size = "md",
|
||||
showIcon = true,
|
||||
className,
|
||||
}: PriceDisplayProps) {
|
||||
const isPositive = priceChange !== undefined && priceChange > 0;
|
||||
const isNegative = priceChange !== undefined && priceChange < 0;
|
||||
const isNeutral = priceChange === undefined || priceChange === 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-2", className)}>
|
||||
<span className={sizeClasses[size].price}>{formatPrice(price)}</span>
|
||||
{priceChange !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
sizeClasses[size].change,
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
isNeutral && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{showIcon && (
|
||||
<>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{isNeutral && <Minus className="h-3 w-3" />}
|
||||
</>
|
||||
)}
|
||||
{formatPercent(priceChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
surfsense_web/components/crypto/SafetyBadge.tsx
Normal file
64
surfsense_web/components/crypto/SafetyBadge.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, ShieldAlert, ShieldCheck, ShieldX } from "lucide-react";
|
||||
import { getSafetyLabel } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface SafetyBadgeProps {
|
||||
score: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showScore?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { badge: "px-1.5 py-0.5 text-xs", icon: "h-3 w-3" },
|
||||
md: { badge: "px-2 py-1 text-sm", icon: "h-4 w-4" },
|
||||
lg: { badge: "px-3 py-1.5 text-base", icon: "h-5 w-5" },
|
||||
};
|
||||
|
||||
function getScoreConfig(score: number) {
|
||||
if (score >= 80) {
|
||||
return {
|
||||
color: "bg-green-500/10 text-green-600 border-green-500/20",
|
||||
Icon: ShieldCheck,
|
||||
};
|
||||
}
|
||||
if (score >= 60) {
|
||||
return {
|
||||
color: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
|
||||
Icon: Shield,
|
||||
};
|
||||
}
|
||||
if (score >= 40) {
|
||||
return {
|
||||
color: "bg-orange-500/10 text-orange-600 border-orange-500/20",
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: "bg-red-500/10 text-red-600 border-red-500/20",
|
||||
Icon: ShieldX,
|
||||
};
|
||||
}
|
||||
|
||||
export function SafetyBadge({ score, size = "md", showScore = true, className }: SafetyBadgeProps) {
|
||||
const { color, Icon } = getScoreConfig(score);
|
||||
const label = getSafetyLabel(score);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border font-medium",
|
||||
color,
|
||||
sizeClasses[size].badge,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={sizeClasses[size].icon} />
|
||||
<span>{label}</span>
|
||||
{showScore && <span className="opacity-70">({score})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
165
surfsense_web/components/crypto/UserProfileSection.tsx
Normal file
165
surfsense_web/components/crypto/UserProfileSection.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { User, Shield, Target, Bell, Save, Loader2 } from "lucide-react";
|
||||
|
||||
export interface UserProfile {
|
||||
riskTolerance: "conservative" | "moderate" | "aggressive";
|
||||
investmentStyle: "day_trader" | "swing" | "long_term";
|
||||
preferredChains: string[];
|
||||
notifications: {
|
||||
priceAlerts: boolean;
|
||||
whaleAlerts: boolean;
|
||||
newsAlerts: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserProfileSectionProps {
|
||||
profile: UserProfile;
|
||||
onSave: (profile: UserProfile) => void;
|
||||
}
|
||||
|
||||
const CHAINS = ["solana", "ethereum", "base", "arbitrum", "polygon"];
|
||||
|
||||
export function UserProfileSection({ profile: initialProfile, onSave }: UserProfileSectionProps) {
|
||||
const [profile, setProfile] = useState<UserProfile>(initialProfile);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const updateProfile = (updates: Partial<UserProfile>) => {
|
||||
setProfile((prev) => ({ ...prev, ...updates }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const toggleChain = (chain: string) => {
|
||||
const newChains = profile.preferredChains.includes(chain)
|
||||
? profile.preferredChains.filter((c) => c !== chain)
|
||||
: [...profile.preferredChains, chain];
|
||||
updateProfile({ preferredChains: newChains });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
onSave(profile);
|
||||
setIsSaving(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Investment Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your risk preferences and notification settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Risk Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Risk Tolerance
|
||||
</Label>
|
||||
<Select
|
||||
value={profile.riskTolerance}
|
||||
onValueChange={(v) => updateProfile({ riskTolerance: v as UserProfile["riskTolerance"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="conservative">Conservative - Lower risk, stable returns</SelectItem>
|
||||
<SelectItem value="moderate">Moderate - Balanced risk/reward</SelectItem>
|
||||
<SelectItem value="aggressive">Aggressive - Higher risk, higher potential</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Investment Style */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Investment Style
|
||||
</Label>
|
||||
<Select
|
||||
value={profile.investmentStyle}
|
||||
onValueChange={(v) => updateProfile({ investmentStyle: v as UserProfile["investmentStyle"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day_trader">Day Trader - Quick trades, high frequency</SelectItem>
|
||||
<SelectItem value="swing">Swing Trader - Hold for days to weeks</SelectItem>
|
||||
<SelectItem value="long_term">Long Term - Hold for months to years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Preferred Chains */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preferred Chains</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CHAINS.map((chain) => (
|
||||
<Badge
|
||||
key={chain}
|
||||
variant={profile.preferredChains.includes(chain) ? "default" : "outline"}
|
||||
className="cursor-pointer capitalize"
|
||||
onClick={() => toggleChain(chain)}
|
||||
>
|
||||
{chain}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="space-y-4">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Price Alerts</span>
|
||||
<Switch
|
||||
checked={profile.notifications.priceAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, priceAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Whale Activity Alerts</span>
|
||||
<Switch
|
||||
checked={profile.notifications.whaleAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, whaleAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">News & Updates</span>
|
||||
<Switch
|
||||
checked={profile.notifications.newsAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, newsAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isSaving} className="w-full">
|
||||
{isSaving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : <><Save className="h-4 w-4 mr-2" />Save Profile</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
211
surfsense_web/components/crypto/WatchlistTable.tsx
Normal file
211
surfsense_web/components/crypto/WatchlistTable.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Star,
|
||||
Bell,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
ArrowUpDown,
|
||||
Trash2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import { SafetyBadge } from "./SafetyBadge";
|
||||
import type { WatchlistToken } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface WatchlistTableProps {
|
||||
tokens: WatchlistToken[];
|
||||
onTokenClick?: (token: WatchlistToken) => void;
|
||||
onRemoveToken?: (tokenId: string) => void;
|
||||
onConfigureAlerts?: (token: WatchlistToken) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SortField = "symbol" | "price" | "priceChange24h" | "volume24h" | "marketCap" | "safetyScore";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
export function WatchlistTable({
|
||||
tokens,
|
||||
onTokenClick,
|
||||
onRemoveToken,
|
||||
onConfigureAlerts,
|
||||
className,
|
||||
}: WatchlistTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("priceChange24h");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedTokens = [...tokens].sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return sortDirection === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||||
}
|
||||
return sortDirection === "asc"
|
||||
? (aVal as number) - (bVal as number)
|
||||
: (bVal as number) - (aVal as number);
|
||||
});
|
||||
|
||||
const SortableHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
onClick={() => handleSort(field)}
|
||||
>
|
||||
{children}
|
||||
<ArrowUpDown className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" /> Watchlist
|
||||
<Badge variant="secondary" className="ml-2">{tokens.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">
|
||||
<SortableHeader field="symbol">Token</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader field="price">Price</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader field="priceChange24h">24h</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<SortableHeader field="volume24h">Volume</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
<SortableHeader field="marketCap">MCap</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
<SortableHeader field="safetyScore">Safety</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedTokens.map((token) => (
|
||||
<TableRow
|
||||
key={token.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{token.symbol}
|
||||
{token.hasAlerts && (
|
||||
<Bell className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{token.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{formatPrice(token.price)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
token.priceChange24h > 0 && "text-green-500",
|
||||
token.priceChange24h < 0 && "text-red-500"
|
||||
)}>
|
||||
{formatPercent(token.priceChange24h)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{formatLargeNumber(token.volume24h)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{formatLargeNumber(token.marketCap)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<SafetyBadge score={token.safetyScore} size="sm" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigureAlerts?.(token);
|
||||
}}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure Alerts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`https://dexscreener.com/${token.chain}/${token.contractAddress}`, "_blank");
|
||||
}}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View on DexScreener
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveToken?.(token.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
12
surfsense_web/components/crypto/index.ts
Normal file
12
surfsense_web/components/crypto/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { PriceDisplay } from "./PriceDisplay";
|
||||
export { SafetyBadge } from "./SafetyBadge";
|
||||
export { ChainIcon } from "./ChainIcon";
|
||||
export { MarketOverview } from "./MarketOverview";
|
||||
export { WatchlistTable } from "./WatchlistTable";
|
||||
export { AlertsPanel } from "./AlertsPanel";
|
||||
export { PortfolioSummary } from "./PortfolioSummary";
|
||||
|
||||
// Modal Components
|
||||
export { AddTokenModal } from "./AddTokenModal";
|
||||
export { CreateAlertModal, type AlertConfig } from "./CreateAlertModal";
|
||||
export { UserProfileSection, type UserProfile } from "./UserProfileSection";
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { Coins, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -241,6 +241,12 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Crypto",
|
||||
url: `/dashboard/${searchSpaceId}/crypto`,
|
||||
icon: Coins,
|
||||
isActive: pathname?.includes("/crypto"),
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||
);
|
||||
|
|
|
|||
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal file
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle, Star, Bell, Trash2, Eye, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for action confirmation tool arguments
|
||||
export const ActionConfirmationArgsSchema = z.object({
|
||||
actionType: z.enum(["watchlist_add", "watchlist_remove", "alert_set", "alert_delete"]),
|
||||
tokenSymbol: z.string(),
|
||||
details: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type ActionConfirmationArgs = z.infer<typeof ActionConfirmationArgsSchema>;
|
||||
|
||||
// Schema for action confirmation result
|
||||
export const ActionConfirmationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ActionConfirmationResult = z.infer<typeof ActionConfirmationResultSchema>;
|
||||
|
||||
const ACTION_CONFIG = {
|
||||
watchlist_add: {
|
||||
icon: Star,
|
||||
title: "Added to Watchlist",
|
||||
iconColor: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
watchlist_remove: {
|
||||
icon: Trash2,
|
||||
title: "Removed from Watchlist",
|
||||
iconColor: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
alert_set: {
|
||||
icon: Bell,
|
||||
title: "Alert Created",
|
||||
iconColor: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
alert_delete: {
|
||||
icon: Trash2,
|
||||
title: "Alert Deleted",
|
||||
iconColor: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ActionConfirmationToolUI - Shows confirmation when AI executes actions
|
||||
* Used for watchlist add/remove, alert set/delete confirmations
|
||||
*/
|
||||
export const ActionConfirmationToolUI = makeAssistantToolUI<ActionConfirmationArgs, ActionConfirmationResult>({
|
||||
toolName: "confirm_action",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const config = ACTION_CONFIG[args.actionType];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card className={cn("my-3 overflow-hidden border-l-4",
|
||||
args.actionType.includes("add") || args.actionType === "alert_set"
|
||||
? "border-l-green-500"
|
||||
: "border-l-red-500"
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn("p-2 rounded-full", config.bgColor)}>
|
||||
{isLoading ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4", config.iconColor)} />
|
||||
<span className="font-medium">{config.title}</span>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{args.tokenSymbol}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{args.details && args.details.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mb-1">Default monitoring enabled:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{args.details.map((detail, i) => (
|
||||
<li key={i}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result message */}
|
||||
{result?.message && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-4 ml-11">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
View Watchlist
|
||||
</Button>
|
||||
{(args.actionType === "watchlist_add" || args.actionType === "alert_set") && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Edit Alerts
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bell, TrendingUp, TrendingDown, Percent, DollarSign, Activity, Trash2, Edit2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// Schema for alert configuration
|
||||
const AlertConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["price_above", "price_below", "percent_change", "volume_spike", "whale_activity"]),
|
||||
value: z.number(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Schema for alert configuration tool arguments
|
||||
export const AlertConfigurationArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
tokenName: z.string().optional(),
|
||||
alerts: z.array(AlertConfigSchema),
|
||||
});
|
||||
|
||||
export type AlertConfigurationArgs = z.infer<typeof AlertConfigurationArgsSchema>;
|
||||
|
||||
// Schema for alert configuration result
|
||||
export const AlertConfigurationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AlertConfigurationResult = z.infer<typeof AlertConfigurationResultSchema>;
|
||||
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_above: { icon: TrendingUp, label: "Price Above", color: "text-green-500" },
|
||||
price_below: { icon: TrendingDown, label: "Price Below", color: "text-red-500" },
|
||||
percent_change: { icon: Percent, label: "% Change", color: "text-blue-500" },
|
||||
volume_spike: { icon: Activity, label: "Volume Spike", color: "text-purple-500" },
|
||||
whale_activity: { icon: DollarSign, label: "Whale Activity", color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const formatValue = (type: string, value: number): string => {
|
||||
if (type === "percent_change") return `${value > 0 ? "+" : ""}${value}%`;
|
||||
if (type === "volume_spike") return `${value}x normal`;
|
||||
if (type === "whale_activity") return `>${value.toLocaleString()} USD`;
|
||||
return `$${value < 1 ? value.toFixed(6) : value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* AlertConfigurationToolUI - Displays/edits alert configurations for a token
|
||||
* Used when AI responds to "set alert for BULLA" or "show my alerts for BULLA"
|
||||
*/
|
||||
export const AlertConfigurationToolUI = makeAssistantToolUI<AlertConfigurationArgs, AlertConfigurationResult>({
|
||||
toolName: "configure_alerts",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const alerts = args.alerts || [];
|
||||
const enabledCount = alerts.filter(a => a.enabled).length;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-blue-500" />
|
||||
Alerts for {args.tokenSymbol}
|
||||
<Badge variant="secondary">{enabledCount} active</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-4 w-4 mr-1" />
|
||||
Add Alert
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<Bell className="h-10 w-10 mx-auto mb-2 opacity-50" />
|
||||
<p>No alerts configured</p>
|
||||
<p className="text-sm">Say "Alert me if {args.tokenSymbol} drops 20%"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={alert.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<div>
|
||||
<p className="font-medium">{config.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatValue(alert.type, alert.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={alert.enabled} />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal file
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Users, AlertTriangle, Shield, Crown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for holder
|
||||
const HolderSchema = z.object({
|
||||
rank: z.number(),
|
||||
address: z.string(),
|
||||
label: z.string().optional(),
|
||||
balance: z.number(),
|
||||
percentage: z.number(),
|
||||
isContract: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema for holder analysis tool arguments
|
||||
export const HolderAnalysisArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
chain: z.string(),
|
||||
totalHolders: z.number(),
|
||||
top10Percentage: z.number(),
|
||||
top50Percentage: z.number().optional(),
|
||||
holders: z.array(HolderSchema),
|
||||
concentrationRisk: z.enum(["low", "medium", "high", "critical"]).optional(),
|
||||
});
|
||||
|
||||
export type HolderAnalysisArgs = z.infer<typeof HolderAnalysisArgsSchema>;
|
||||
|
||||
// Schema for holder analysis result
|
||||
export const HolderAnalysisResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type HolderAnalysisResult = z.infer<typeof HolderAnalysisResultSchema>;
|
||||
|
||||
const shortenAddress = (address: string): string => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const formatBalance = (balance: number): string => {
|
||||
if (balance >= 1e9) return `${(balance / 1e9).toFixed(2)}B`;
|
||||
if (balance >= 1e6) return `${(balance / 1e6).toFixed(2)}M`;
|
||||
if (balance >= 1e3) return `${(balance / 1e3).toFixed(2)}K`;
|
||||
return balance.toFixed(2);
|
||||
};
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
switch (risk) {
|
||||
case "low": return "text-green-500 bg-green-500/10";
|
||||
case "medium": return "text-yellow-500 bg-yellow-500/10";
|
||||
case "high": return "text-orange-500 bg-orange-500/10";
|
||||
case "critical": return "text-red-500 bg-red-500/10";
|
||||
default: return "text-muted-foreground bg-muted";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* HolderAnalysisToolUI - Displays holder distribution inline in chat
|
||||
* Used when AI responds to "who holds BULLA?" or "analyze holders"
|
||||
*/
|
||||
export const HolderAnalysisToolUI = makeAssistantToolUI<HolderAnalysisArgs, HolderAnalysisResult>({
|
||||
toolName: "analyze_holders",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const holders = args.holders || [];
|
||||
const risk = args.concentrationRisk || "medium";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
Holder Analysis - {args.tokenSymbol}
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<ChainIcon chain={args.chain} size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium">{args.totalHolders.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn("rounded-lg p-3", args.top10Percentage > 50 ? "bg-red-500/10" : "bg-muted/50")}>
|
||||
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
|
||||
<p className={cn("font-medium", args.top10Percentage > 50 && "text-red-500")}>{args.top10Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
{args.top50Percentage && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium">{args.top50Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("rounded-lg p-3", getRiskColor(risk))}>
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium capitalize">{risk}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Warning */}
|
||||
{(risk === "high" || risk === "critical") && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-3">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>High holder concentration detected. Top wallets could significantly impact price.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Holders List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Top Holders</p>
|
||||
<div className="divide-y max-h-[250px] overflow-y-auto">
|
||||
{holders.slice(0, 10).map((holder) => (
|
||||
<div key={holder.address} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-6">#{holder.rank}</span>
|
||||
{holder.rank <= 3 && <Crown className={cn("h-4 w-4", holder.rank === 1 ? "text-yellow-500" : holder.rank === 2 ? "text-gray-400" : "text-amber-600")} />}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{holder.label || shortenAddress(holder.address)}</p>
|
||||
{holder.isContract && <Badge variant="outline" className="text-xs">Contract</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBalance(holder.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal file
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BarChart3, TrendingUp, TrendingDown, Globe } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for market token
|
||||
const MarketTokenSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
marketCap: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for market overview tool arguments
|
||||
export const MarketOverviewArgsSchema = z.object({
|
||||
tokens: z.array(MarketTokenSchema),
|
||||
totalMarketCap: z.number().optional(),
|
||||
totalVolume24h: z.number().optional(),
|
||||
btcDominance: z.number().optional(),
|
||||
fearGreedIndex: z.number().optional(),
|
||||
});
|
||||
|
||||
export type MarketOverviewArgs = z.infer<typeof MarketOverviewArgsSchema>;
|
||||
|
||||
// Schema for market overview result
|
||||
export const MarketOverviewResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MarketOverviewResult = z.infer<typeof MarketOverviewResultSchema>;
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 1) return `$${price.toFixed(4)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* MarketOverviewToolUI - Displays market overview inline in chat
|
||||
* Used when AI responds to "show market overview" or "how's the market?"
|
||||
*/
|
||||
export const MarketOverviewToolUI = makeAssistantToolUI<MarketOverviewArgs, MarketOverviewResult>({
|
||||
toolName: "get_market_overview",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
Market Overview
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Global Stats */}
|
||||
{(args.totalMarketCap || args.btcDominance || args.fearGreedIndex) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{args.totalMarketCap && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.totalMarketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.totalVolume24h && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.totalVolume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.btcDominance && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium">{args.btcDominance.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{args.fearGreedIndex && (
|
||||
<div className={cn("rounded-lg p-3", args.fearGreedIndex > 50 ? "bg-green-500/10" : "bg-red-500/10")}>
|
||||
<p className="text-xs text-muted-foreground">Fear & Greed</p>
|
||||
<p className={cn("font-medium", args.fearGreedIndex > 50 ? "text-green-500" : "text-red-500")}>
|
||||
{args.fearGreedIndex} - {args.fearGreedIndex > 75 ? "Extreme Greed" : args.fearGreedIndex > 50 ? "Greed" : args.fearGreedIndex > 25 ? "Fear" : "Extreme Fear"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Prices */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.symbol} className="bg-muted/50 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-lg">{token.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm flex items-center justify-end gap-0.5", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Wallet, TrendingUp, TrendingDown, PieChart } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for portfolio holding
|
||||
const HoldingSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
balance: z.number(),
|
||||
value: z.number(),
|
||||
costBasis: z.number().optional(),
|
||||
pnl: z.number().optional(),
|
||||
pnlPercent: z.number().optional(),
|
||||
allocation: z.number(),
|
||||
});
|
||||
|
||||
// Schema for portfolio display tool arguments
|
||||
export const PortfolioDisplayArgsSchema = z.object({
|
||||
holdings: z.array(HoldingSchema),
|
||||
totalValue: z.number(),
|
||||
totalPnl: z.number().optional(),
|
||||
totalPnlPercent: z.number().optional(),
|
||||
lastUpdated: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PortfolioDisplayArgs = z.infer<typeof PortfolioDisplayArgsSchema>;
|
||||
|
||||
// Schema for portfolio display result
|
||||
export const PortfolioDisplayResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PortfolioDisplayResult = z.infer<typeof PortfolioDisplayResultSchema>;
|
||||
|
||||
const formatValue = (value: number): string => {
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* PortfolioDisplayToolUI - Displays user's portfolio inline in chat
|
||||
* Used when AI responds to "how's my portfolio?" or "show my holdings"
|
||||
*/
|
||||
export const PortfolioDisplayToolUI = makeAssistantToolUI<PortfolioDisplayArgs, PortfolioDisplayResult>({
|
||||
toolName: "get_portfolio",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const holdings = args.holdings || [];
|
||||
const hasPnl = args.totalPnl !== undefined;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-emerald-500" />
|
||||
Your Portfolio
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
{args.lastUpdated && (
|
||||
<span className="text-xs text-muted-foreground">Updated {args.lastUpdated}</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Total Value */}
|
||||
<div className="bg-gradient-to-r from-emerald-500/10 to-blue-500/10 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Value</p>
|
||||
<p className="text-3xl font-bold">{formatValue(args.totalValue)}</p>
|
||||
{hasPnl && (
|
||||
<p className={cn("text-sm flex items-center gap-1 mt-1", (args.totalPnl || 0) >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{(args.totalPnl || 0) >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{(args.totalPnl || 0) >= 0 ? "+" : ""}{formatValue(args.totalPnl || 0)} ({(args.totalPnlPercent || 0) >= 0 ? "+" : ""}{(args.totalPnlPercent || 0).toFixed(2)}%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holdings List */}
|
||||
{holdings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">No holdings found</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{holdings.map((holding) => (
|
||||
<div key={holding.symbol} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={holding.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{holding.symbol}</span>
|
||||
<Badge variant="secondary" className="text-xs">{holding.allocation.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{holding.balance.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatValue(holding.value)}</p>
|
||||
{holding.pnlPercent !== undefined && (
|
||||
<p className={cn("text-sm", holding.pnlPercent >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{holding.pnlPercent >= 0 ? "+" : ""}{holding.pnlPercent.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal file
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangle, TrendingUp, TrendingDown, Activity, Zap, Eye, Bell, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for proactive alert tool arguments
|
||||
export const ProactiveAlertArgsSchema = z.object({
|
||||
alertType: z.enum(["price_surge", "price_drop", "whale_buy", "whale_sell", "volume_spike", "safety_warning"]),
|
||||
tokenSymbol: z.string(),
|
||||
tokenName: z.string().optional(),
|
||||
value: z.number(),
|
||||
previousValue: z.number().optional(),
|
||||
message: z.string(),
|
||||
severity: z.enum(["info", "warning", "critical"]).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ProactiveAlertArgs = z.infer<typeof ProactiveAlertArgsSchema>;
|
||||
|
||||
// Schema for proactive alert result
|
||||
export const ProactiveAlertResultSchema = z.object({
|
||||
acknowledged: z.boolean(),
|
||||
});
|
||||
|
||||
export type ProactiveAlertResult = z.infer<typeof ProactiveAlertResultSchema>;
|
||||
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_surge: { icon: TrendingUp, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
|
||||
price_drop: { icon: TrendingDown, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
|
||||
whale_buy: { icon: Zap, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
|
||||
whale_sell: { icon: Zap, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
|
||||
volume_spike: { icon: Activity, color: "text-purple-500", bgColor: "bg-purple-500/10", borderColor: "border-l-purple-500" },
|
||||
safety_warning: { icon: AlertTriangle, color: "text-yellow-500", bgColor: "bg-yellow-500/10", borderColor: "border-l-yellow-500" },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
info: { badge: "secondary", pulse: false },
|
||||
warning: { badge: "warning", pulse: false },
|
||||
critical: { badge: "destructive", pulse: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* ProactiveAlertToolUI - Displays AI-initiated alerts in chat
|
||||
* Used when AI proactively notifies user about price changes, whale activity, etc.
|
||||
*/
|
||||
export const ProactiveAlertToolUI = makeAssistantToolUI<ProactiveAlertArgs, ProactiveAlertResult>({
|
||||
toolName: "proactive_alert",
|
||||
render: ({ args, result }) => {
|
||||
const config = ALERT_TYPE_CONFIG[args.alertType];
|
||||
const severity = args.severity || "info";
|
||||
const severityConfig = SEVERITY_CONFIG[severity];
|
||||
const Icon = config.icon;
|
||||
const isAcknowledged = result?.acknowledged;
|
||||
|
||||
const formatChange = () => {
|
||||
if (args.previousValue === undefined) return null;
|
||||
const change = ((args.value - args.previousValue) / args.previousValue) * 100;
|
||||
return change;
|
||||
};
|
||||
|
||||
const change = formatChange();
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"my-3 overflow-hidden border-l-4 transition-all",
|
||||
config.borderColor,
|
||||
isAcknowledged && "opacity-60"
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Alert Icon */}
|
||||
<div className={cn(
|
||||
"p-2 rounded-full",
|
||||
config.bgColor,
|
||||
severityConfig.pulse && "animate-pulse"
|
||||
)}>
|
||||
<Icon className={cn("h-5 w-5", config.color)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={severityConfig.badge as any} className="uppercase text-xs">
|
||||
{args.alertType.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="font-bold">{args.tokenSymbol}</span>
|
||||
{change !== null && (
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
change >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{change >= 0 ? "+" : ""}{change.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{args.timestamp && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{args.timestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm">{args.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Dismiss */}
|
||||
{!isAcknowledged && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isAcknowledged && (
|
||||
<div className="flex gap-2 mt-3 ml-11">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
Adjust Alert
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal file
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, Star, Bell, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
import { SafetyBadge } from "@/components/crypto/SafetyBadge";
|
||||
|
||||
// Schema for token analysis tool arguments
|
||||
export const TokenAnalysisArgsSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string().optional(),
|
||||
chain: z.string(),
|
||||
contractAddress: z.string().optional(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
marketCap: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
liquidity: z.number().optional(),
|
||||
safetyScore: z.number().optional(),
|
||||
holderCount: z.number().optional(),
|
||||
top10HolderPercent: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TokenAnalysisArgs = z.infer<typeof TokenAnalysisArgsSchema>;
|
||||
|
||||
// Schema for token analysis result
|
||||
export const TokenAnalysisResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
isInWatchlist: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TokenAnalysisResult = z.infer<typeof TokenAnalysisResultSchema>;
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TokenAnalysisToolUI - Displays comprehensive token analysis in chat
|
||||
* Used when AI responds to token research queries like "analyze BULLA" or "is BULLA safe?"
|
||||
*/
|
||||
export const TokenAnalysisToolUI = makeAssistantToolUI<TokenAnalysisArgs, TokenAnalysisResult>({
|
||||
toolName: "analyze_token",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const isInWatchlist = result?.isInWatchlist ?? false;
|
||||
|
||||
const handleOpenDexScreener = () => {
|
||||
if (args.contractAddress) {
|
||||
window.open(`https://dexscreener.com/${args.chain}/${args.contractAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
Token Analysis
|
||||
{isLoading && <Badge variant="secondary" className="animate-pulse">Analyzing...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{args.symbol}</span>
|
||||
{args.name && <span className="text-muted-foreground text-sm">{args.name}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatPrice(args.price)}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
args.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{args.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{args.priceChange24h >= 0 ? "+" : ""}{args.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{args.safetyScore !== undefined && (
|
||||
<SafetyBadge score={args.safetyScore} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{args.marketCap && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.marketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.volume24h && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.liquidity && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.liquidity)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.holderCount && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Holders</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{args.holderCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holder Concentration Warning */}
|
||||
{args.top10HolderPercent && args.top10HolderPercent > 50 && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Top 10 holders own {args.top10HolderPercent}% of supply - high concentration risk</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
<Star className={cn("h-4 w-4 mr-2", isInWatchlist && "fill-yellow-500 text-yellow-500")} />
|
||||
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal file
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flame, TrendingUp, TrendingDown, Star, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for trending token
|
||||
const TrendingTokenSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
contractAddress: z.string().optional(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
priceChange1h: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
liquidity: z.number().optional(),
|
||||
rank: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for trending tokens tool arguments
|
||||
export const TrendingTokensArgsSchema = z.object({
|
||||
chain: z.string().optional(),
|
||||
tokens: z.array(TrendingTokenSchema),
|
||||
timeframe: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TrendingTokensArgs = z.infer<typeof TrendingTokensArgsSchema>;
|
||||
|
||||
// Schema for trending tokens result
|
||||
export const TrendingTokensResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TrendingTokensResult = z.infer<typeof TrendingTokensResultSchema>;
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TrendingTokensToolUI - Displays trending/hot tokens inline in chat
|
||||
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
|
||||
*/
|
||||
export const TrendingTokensToolUI = makeAssistantToolUI<TrendingTokensArgs, TrendingTokensResult>({
|
||||
toolName: "get_trending_tokens",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
const chain = args.chain || "all chains";
|
||||
const timeframe = args.timeframe || "24h";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
Trending on {chain}
|
||||
<Badge variant="secondary">{timeframe}</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">No trending tokens found</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{tokens.map((token, index) => (
|
||||
<div key={token.symbol + index} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted-foreground w-6">#{token.rank || index + 1}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm flex items-center justify-end gap-0.5", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
{token.volume24h && (
|
||||
<div className="text-right hidden md:block">
|
||||
<p className="text-xs text-muted-foreground">Volume</p>
|
||||
<p className="text-sm">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal file
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { User, Shield, Target, Clock, Zap } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for user profile tool arguments
|
||||
export const UserProfileArgsSchema = z.object({
|
||||
riskTolerance: z.enum(["conservative", "moderate", "aggressive"]),
|
||||
investmentStyle: z.enum(["day_trader", "swing", "long_term"]),
|
||||
preferredChains: z.array(z.string()),
|
||||
portfolioSizeRange: z.enum(["small", "medium", "large"]).optional(),
|
||||
experienceLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
notificationPreferences: z.object({
|
||||
priceAlerts: z.boolean(),
|
||||
whaleAlerts: z.boolean(),
|
||||
newsAlerts: z.boolean(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type UserProfileArgs = z.infer<typeof UserProfileArgsSchema>;
|
||||
|
||||
// Schema for user profile result
|
||||
export const UserProfileResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserProfileResult = z.infer<typeof UserProfileResultSchema>;
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
switch (risk) {
|
||||
case "conservative": return "text-green-500 bg-green-500/10 border-green-500/20";
|
||||
case "moderate": return "text-yellow-500 bg-yellow-500/10 border-yellow-500/20";
|
||||
case "aggressive": return "text-red-500 bg-red-500/10 border-red-500/20";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStyleIcon = (style: string) => {
|
||||
switch (style) {
|
||||
case "day_trader": return <Zap className="h-4 w-4" />;
|
||||
case "swing": return <Target className="h-4 w-4" />;
|
||||
case "long_term": return <Clock className="h-4 w-4" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatStyle = (style: string) => {
|
||||
switch (style) {
|
||||
case "day_trader": return "Day Trader";
|
||||
case "swing": return "Swing Trader";
|
||||
case "long_term": return "Long Term Investor";
|
||||
default: return style;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UserProfileToolUI - Displays user's investment profile inline in chat
|
||||
* Used when AI responds to "show my profile" or "what's my risk setting?"
|
||||
*/
|
||||
export const UserProfileToolUI = makeAssistantToolUI<UserProfileArgs, UserProfileResult>({
|
||||
toolName: "get_user_profile",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-indigo-500" />
|
||||
Your Investment Profile
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Main Profile Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Risk Tolerance */}
|
||||
<div className={cn("rounded-lg p-4 border", getRiskColor(args.riskTolerance))}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Risk Tolerance</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold capitalize">{args.riskTolerance}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{args.riskTolerance === "conservative" && "Prefer stable, lower-risk investments"}
|
||||
{args.riskTolerance === "moderate" && "Balance between risk and reward"}
|
||||
{args.riskTolerance === "aggressive" && "Willing to take higher risks for higher returns"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Investment Style */}
|
||||
<div className="rounded-lg p-4 border bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getStyleIcon(args.investmentStyle)}
|
||||
<span className="text-sm font-medium">Investment Style</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatStyle(args.investmentStyle)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{args.investmentStyle === "day_trader" && "Quick trades, high frequency"}
|
||||
{args.investmentStyle === "swing" && "Hold for days to weeks"}
|
||||
{args.investmentStyle === "long_term" && "Hold for months to years"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred Chains */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Preferred Chains</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{args.preferredChains.map((chain) => (
|
||||
<Badge key={chain} variant="secondary" className="capitalize">{chain}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Preferences */}
|
||||
{args.notificationPreferences && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Notifications</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{args.notificationPreferences.priceAlerts && <Badge variant="outline">Price Alerts</Badge>}
|
||||
{args.notificationPreferences.whaleAlerts && <Badge variant="outline">Whale Alerts</Badge>}
|
||||
{args.notificationPreferences.newsAlerts && <Badge variant="outline">News Alerts</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Hint */}
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
Say "update my risk tolerance to moderate" to change settings
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal file
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Star, TrendingUp, TrendingDown, Bell, Trash2, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for watchlist token
|
||||
const WatchlistTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
alertCount: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for watchlist display tool arguments
|
||||
export const WatchlistDisplayArgsSchema = z.object({
|
||||
tokens: z.array(WatchlistTokenSchema),
|
||||
});
|
||||
|
||||
export type WatchlistDisplayArgs = z.infer<typeof WatchlistDisplayArgsSchema>;
|
||||
|
||||
// Schema for watchlist display result
|
||||
export const WatchlistDisplayResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WatchlistDisplayResult = z.infer<typeof WatchlistDisplayResultSchema>;
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* WatchlistDisplayToolUI - Displays user's watchlist inline in chat
|
||||
* Used when AI responds to "show my watchlist" or similar commands
|
||||
*/
|
||||
export const WatchlistDisplayToolUI = makeAssistantToolUI<WatchlistDisplayArgs, WatchlistDisplayResult>({
|
||||
toolName: "show_watchlist",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
<Card className="my-3">
|
||||
<CardContent className="py-8 text-center">
|
||||
<Star className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">Your watchlist is empty</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Say "Add [token] to my watchlist" to start tracking
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Find best and worst performers
|
||||
const sortedByChange = [...tokens].sort((a, b) => b.priceChange24h - a.priceChange24h);
|
||||
const bestPerformer = sortedByChange[0];
|
||||
const worstPerformer = sortedByChange[sortedByChange.length - 1];
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
Your Watchlist
|
||||
<Badge variant="secondary">{tokens.length}</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Token List */}
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
{token.alertCount && token.alertCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
<Bell className="h-2.5 w-2.5 mr-0.5" />
|
||||
{token.alertCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-sm flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{tokens.length > 1 && (
|
||||
<div className="pt-3 border-t text-sm text-muted-foreground">
|
||||
<span className="text-green-500 font-medium">{bestPerformer.symbol}</span> is your best performer (+{bestPerformer.priceChange24h.toFixed(1)}%)
|
||||
{worstPerformer.priceChange24h < 0 && (
|
||||
<span> • <span className="text-red-500 font-medium">{worstPerformer.symbol}</span> needs attention ({worstPerformer.priceChange24h.toFixed(1)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal file
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Fish, ArrowUpRight, ArrowDownRight, ExternalLink, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for whale transaction
|
||||
const WhaleTransactionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["buy", "sell", "transfer"]),
|
||||
amount: z.number(),
|
||||
amountUsd: z.number(),
|
||||
tokenSymbol: z.string(),
|
||||
walletAddress: z.string(),
|
||||
walletLabel: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
txHash: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema for whale activity tool arguments
|
||||
export const WhaleActivityArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
chain: z.string(),
|
||||
transactions: z.array(WhaleTransactionSchema),
|
||||
summary: z.object({
|
||||
totalBuyVolume: z.number(),
|
||||
totalSellVolume: z.number(),
|
||||
netFlow: z.number(),
|
||||
uniqueWhales: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type WhaleActivityArgs = z.infer<typeof WhaleActivityArgsSchema>;
|
||||
|
||||
// Schema for whale activity result
|
||||
export const WhaleActivityResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WhaleActivityResult = z.infer<typeof WhaleActivityResultSchema>;
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const shortenAddress = (address: string): string => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string): string => {
|
||||
const diff = Date.now() - new Date(timestamp).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
/**
|
||||
* WhaleActivityToolUI - Displays whale transactions inline in chat
|
||||
* Used when AI responds to "show whale activity for BULLA" or similar
|
||||
*/
|
||||
export const WhaleActivityToolUI = makeAssistantToolUI<WhaleActivityArgs, WhaleActivityResult>({
|
||||
toolName: "get_whale_activity",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const transactions = args.transactions || [];
|
||||
const summary = args.summary;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="h-5 w-5 text-blue-500" />
|
||||
Whale Activity - {args.tokenSymbol}
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<ChainIcon chain={args.chain} size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Buy Volume</p>
|
||||
<p className="font-medium text-green-500">{formatLargeNumber(summary.totalBuyVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Sell Volume</p>
|
||||
<p className="font-medium text-red-500">{formatLargeNumber(summary.totalSellVolume)}</p>
|
||||
</div>
|
||||
<div className={cn("rounded-lg p-3", summary.netFlow >= 0 ? "bg-green-500/10" : "bg-red-500/10")}>
|
||||
<p className="text-xs text-muted-foreground">Net Flow</p>
|
||||
<p className={cn("font-medium", summary.netFlow >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{summary.netFlow >= 0 ? "+" : ""}{formatLargeNumber(summary.netFlow)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Unique Whales</p>
|
||||
<p className="font-medium">{summary.uniqueWhales}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction List */}
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">No whale transactions detected</p>
|
||||
) : (
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-full", tx.type === "buy" ? "bg-green-500/10" : tx.type === "sell" ? "bg-red-500/10" : "bg-muted")}>
|
||||
{tx.type === "buy" ? <ArrowUpRight className="h-4 w-4 text-green-500" /> : tx.type === "sell" ? <ArrowDownRight className="h-4 w-4 text-red-500" /> : <ArrowUpRight className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-medium capitalize", tx.type === "buy" ? "text-green-500" : tx.type === "sell" ? "text-red-500" : "")}>
|
||||
{tx.type}
|
||||
</span>
|
||||
<span className="font-medium">{formatLargeNumber(tx.amountUsd)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{tx.walletLabel || shortenAddress(tx.walletAddress)}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimeAgo(tx.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tx.txHash && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -91,3 +91,37 @@ export {
|
|||
SaveMemoryToolUI,
|
||||
} from "./user-memory";
|
||||
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
||||
|
||||
// Crypto Tool UI Components - Conversational Crypto Advisor
|
||||
export {
|
||||
// Token Analysis
|
||||
TokenAnalysisToolUI,
|
||||
TokenAnalysisArgsSchema,
|
||||
TokenAnalysisResultSchema,
|
||||
type TokenAnalysisArgs,
|
||||
type TokenAnalysisResult,
|
||||
// Watchlist Display
|
||||
WatchlistDisplayToolUI,
|
||||
WatchlistDisplayArgsSchema,
|
||||
WatchlistDisplayResultSchema,
|
||||
type WatchlistDisplayArgs,
|
||||
type WatchlistDisplayResult,
|
||||
// Action Confirmation
|
||||
ActionConfirmationToolUI,
|
||||
ActionConfirmationArgsSchema,
|
||||
ActionConfirmationResultSchema,
|
||||
type ActionConfirmationArgs,
|
||||
type ActionConfirmationResult,
|
||||
// Alert Configuration
|
||||
AlertConfigurationToolUI,
|
||||
AlertConfigurationArgsSchema,
|
||||
AlertConfigurationResultSchema,
|
||||
type AlertConfigurationArgs,
|
||||
type AlertConfigurationResult,
|
||||
// Proactive Alert
|
||||
ProactiveAlertToolUI,
|
||||
ProactiveAlertArgsSchema,
|
||||
ProactiveAlertResultSchema,
|
||||
type ProactiveAlertArgs,
|
||||
type ProactiveAlertResult,
|
||||
} from "./crypto";
|
||||
|
|
|
|||
467
surfsense_web/lib/mock/cryptoMockData.ts
Normal file
467
surfsense_web/lib/mock/cryptoMockData.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
/**
|
||||
* Mock data for Crypto Dashboard - SurfSense Web V2
|
||||
* Remove or disable in production
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export type ChainType = "solana" | "ethereum" | "base" | "arbitrum" | "polygon" | "bsc";
|
||||
|
||||
export interface TokenPrice {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
priceChange7d: number;
|
||||
volume24h: number;
|
||||
marketCap: number;
|
||||
chain: ChainType;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface WatchlistToken {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: ChainType;
|
||||
contractAddress: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
volume24h: number;
|
||||
marketCap: number;
|
||||
liquidity: number;
|
||||
safetyScore: number;
|
||||
hasAlerts: boolean;
|
||||
alertCount?: number;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
chain: ChainType;
|
||||
type: "price_above" | "price_below" | "price_change" | "volume" | "whale" | "liquidity" | "safety";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
isRead: boolean;
|
||||
severity: "info" | "warning" | "critical";
|
||||
}
|
||||
|
||||
export interface PortfolioToken {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: ChainType;
|
||||
amount: number;
|
||||
avgBuyPrice: number;
|
||||
currentPrice: number;
|
||||
value: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
allocation: number;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number;
|
||||
change24h: number;
|
||||
change24hPercent: number;
|
||||
tokens: PortfolioToken[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MARKET OVERVIEW DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_MARKET_PRICES: TokenPrice[] = [
|
||||
{
|
||||
symbol: "BTC",
|
||||
name: "Bitcoin",
|
||||
price: 97542.18,
|
||||
priceChange24h: 2.34,
|
||||
priceChange7d: 8.12,
|
||||
volume24h: 42_500_000_000,
|
||||
marketCap: 1_920_000_000_000,
|
||||
chain: "ethereum",
|
||||
icon: "₿",
|
||||
},
|
||||
{
|
||||
symbol: "ETH",
|
||||
name: "Ethereum",
|
||||
price: 3456.78,
|
||||
priceChange24h: -1.23,
|
||||
priceChange7d: 5.67,
|
||||
volume24h: 18_200_000_000,
|
||||
marketCap: 415_000_000_000,
|
||||
chain: "ethereum",
|
||||
icon: "Ξ",
|
||||
},
|
||||
{
|
||||
symbol: "SOL",
|
||||
name: "Solana",
|
||||
price: 198.45,
|
||||
priceChange24h: 5.67,
|
||||
priceChange7d: 12.34,
|
||||
volume24h: 4_500_000_000,
|
||||
marketCap: 92_000_000_000,
|
||||
chain: "solana",
|
||||
icon: "◎",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_TRENDING_TOKENS: TokenPrice[] = [
|
||||
{
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
priceChange7d: 342.5,
|
||||
volume24h: 1_200_000,
|
||||
marketCap: 2_100_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
price: 0.00002156,
|
||||
priceChange24h: 12.3,
|
||||
priceChange7d: 45.6,
|
||||
volume24h: 89_000_000,
|
||||
marketCap: 1_450_000_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
price: 2.45,
|
||||
priceChange24h: -5.2,
|
||||
priceChange7d: 23.4,
|
||||
volume24h: 245_000_000,
|
||||
marketCap: 2_450_000_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
price: 0.00001089,
|
||||
priceChange24h: 8.7,
|
||||
priceChange7d: -12.3,
|
||||
volume24h: 567_000_000,
|
||||
marketCap: 4_580_000_000,
|
||||
chain: "ethereum",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// WATCHLIST DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_WATCHLIST: WatchlistToken[] = [
|
||||
{
|
||||
id: "1",
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
volume24h: 1_200_000,
|
||||
marketCap: 2_100_000,
|
||||
liquidity: 450_000,
|
||||
safetyScore: 72,
|
||||
hasAlerts: true,
|
||||
alertCount: 2,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
contractAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
||||
price: 0.00002156,
|
||||
priceChange24h: 12.3,
|
||||
volume24h: 89_000_000,
|
||||
marketCap: 1_450_000_000,
|
||||
liquidity: 45_000_000,
|
||||
safetyScore: 89,
|
||||
hasAlerts: true,
|
||||
alertCount: 1,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
contractAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
price: 2.45,
|
||||
priceChange24h: -5.2,
|
||||
volume24h: 245_000_000,
|
||||
marketCap: 2_450_000_000,
|
||||
liquidity: 125_000_000,
|
||||
safetyScore: 94,
|
||||
hasAlerts: false,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
price: 0.00001089,
|
||||
priceChange24h: 8.7,
|
||||
volume24h: 567_000_000,
|
||||
marketCap: 4_580_000_000,
|
||||
liquidity: 234_000_000,
|
||||
safetyScore: 85,
|
||||
hasAlerts: false,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
contractAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
|
||||
price: 0.0156,
|
||||
priceChange24h: -15.3,
|
||||
volume24h: 12_000_000,
|
||||
marketCap: 156_000_000,
|
||||
liquidity: 8_500_000,
|
||||
safetyScore: 78,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// ALERTS DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_ALERTS: Alert[] = [
|
||||
{
|
||||
id: "alert-1",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
type: "price_above",
|
||||
message: "BULLA price increased above $0.00001 (+156%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||
isRead: false,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
id: "alert-2",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
type: "whale",
|
||||
message: "Large transaction detected: 500M BULLA ($6,170) transferred",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15),
|
||||
isRead: false,
|
||||
severity: "warning",
|
||||
},
|
||||
{
|
||||
id: "alert-3",
|
||||
tokenSymbol: "DEGEN",
|
||||
tokenName: "Degen",
|
||||
chain: "base",
|
||||
type: "price_below",
|
||||
message: "DEGEN dropped below $0.02 (-15%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30),
|
||||
isRead: false,
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "alert-4",
|
||||
tokenSymbol: "BONK",
|
||||
tokenName: "Bonk",
|
||||
chain: "solana",
|
||||
type: "volume",
|
||||
message: "BONK volume spike: 3x average in last hour",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60),
|
||||
isRead: true,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
id: "alert-5",
|
||||
tokenSymbol: "DEGEN",
|
||||
tokenName: "Degen",
|
||||
chain: "base",
|
||||
type: "liquidity",
|
||||
message: "DEGEN liquidity decreased by 12% ($1.2M removed)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 120),
|
||||
isRead: true,
|
||||
severity: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// PORTFOLIO DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_PORTFOLIO: PortfolioSummary = {
|
||||
totalValue: 15_234.56,
|
||||
totalPnl: 3_456.78,
|
||||
totalPnlPercent: 29.34,
|
||||
change24h: 456.12,
|
||||
change24hPercent: 3.08,
|
||||
tokens: [
|
||||
{
|
||||
id: "p1",
|
||||
symbol: "SOL",
|
||||
name: "Solana",
|
||||
chain: "solana",
|
||||
amount: 25.5,
|
||||
avgBuyPrice: 145.00,
|
||||
currentPrice: 198.45,
|
||||
value: 5_060.48,
|
||||
pnl: 1_362.98,
|
||||
pnlPercent: 36.86,
|
||||
allocation: 33.2,
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
symbol: "ETH",
|
||||
name: "Ethereum",
|
||||
chain: "ethereum",
|
||||
amount: 1.2,
|
||||
avgBuyPrice: 2_800.00,
|
||||
currentPrice: 3_456.78,
|
||||
value: 4_148.14,
|
||||
pnl: 788.14,
|
||||
pnlPercent: 23.46,
|
||||
allocation: 27.2,
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
amount: 150_000_000,
|
||||
avgBuyPrice: 0.000015,
|
||||
currentPrice: 0.00002156,
|
||||
value: 3_234.00,
|
||||
pnl: 984.00,
|
||||
pnlPercent: 43.73,
|
||||
allocation: 21.2,
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
amount: 500,
|
||||
avgBuyPrice: 1.80,
|
||||
currentPrice: 2.45,
|
||||
value: 1_225.00,
|
||||
pnl: 325.00,
|
||||
pnlPercent: 36.11,
|
||||
allocation: 8.0,
|
||||
},
|
||||
{
|
||||
id: "p5",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
amount: 100_000_000,
|
||||
avgBuyPrice: 0.000012,
|
||||
currentPrice: 0.00001089,
|
||||
value: 1_089.00,
|
||||
pnl: -111.00,
|
||||
pnlPercent: -9.25,
|
||||
allocation: 7.2,
|
||||
},
|
||||
{
|
||||
id: "p6",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
amount: 30_000,
|
||||
avgBuyPrice: 0.012,
|
||||
currentPrice: 0.0156,
|
||||
value: 468.00,
|
||||
pnl: 108.00,
|
||||
pnlPercent: 30.00,
|
||||
allocation: 3.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
if (price >= 1000) {
|
||||
return `$${price.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
} else if (price >= 1) {
|
||||
return `$${price.toFixed(2)}`;
|
||||
} else if (price >= 0.0001) {
|
||||
return `$${price.toFixed(6)}`;
|
||||
} else {
|
||||
return `$${price.toFixed(10)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLargeNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) {
|
||||
return `$${(num / 1_000_000_000).toFixed(2)}B`;
|
||||
} else if (num >= 1_000_000) {
|
||||
return `$${(num / 1_000_000).toFixed(2)}M`;
|
||||
} else if (num >= 1_000) {
|
||||
return `$${(num / 1_000).toFixed(2)}K`;
|
||||
}
|
||||
return `$${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
const sign = percent >= 0 ? "+" : "";
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function getChainColor(chain: ChainType): string {
|
||||
const colors: Record<ChainType, string> = {
|
||||
solana: "#9945FF",
|
||||
ethereum: "#627EEA",
|
||||
base: "#0052FF",
|
||||
arbitrum: "#28A0F0",
|
||||
polygon: "#8247E5",
|
||||
bsc: "#F0B90B",
|
||||
};
|
||||
return colors[chain] || "#888888";
|
||||
}
|
||||
|
||||
export function getChainName(chain: ChainType): string {
|
||||
const names: Record<ChainType, string> = {
|
||||
solana: "Solana",
|
||||
ethereum: "Ethereum",
|
||||
base: "Base",
|
||||
arbitrum: "Arbitrum",
|
||||
polygon: "Polygon",
|
||||
bsc: "BNB Chain",
|
||||
};
|
||||
return names[chain] || chain;
|
||||
}
|
||||
|
||||
export function getSafetyColor(score: number): string {
|
||||
if (score >= 80) return "#22C55E"; // green
|
||||
if (score >= 60) return "#EAB308"; // yellow
|
||||
if (score >= 40) return "#F97316"; // orange
|
||||
return "#EF4444"; // red
|
||||
}
|
||||
|
||||
export function getSafetyLabel(score: number): string {
|
||||
if (score >= 80) return "Safe";
|
||||
if (score >= 60) return "Medium";
|
||||
if (score >= 40) return "Risky";
|
||||
return "Danger";
|
||||
}
|
||||
|
||||
4
surfsense_web/start-dev.sh
Executable file
4
surfsense_web/start-dev.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
cd /Users/mac_1/Documents/GitHub/SurfSense/surfsense_web
|
||||
pnpm dev
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue