mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
chore: merge upstream with local feature additions
- Merged dexscreener connector, composio connectors, crypto realtime tools from upstream - Kept local additions: dropbox/onedrive connectors, memory routes, model_list routes, RefreshToken model - Resolved frontend conflicts: kept tool UIs from both sides - Accepted upstream lock files (uv.lock, pnpm-lock.yaml)
This commit is contained in:
commit
6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DexScreenerConnectForm } from '@/components/assistant-ui/connector-popup/connect-forms/components/dexscreener-connect-form';
|
||||
|
||||
// Mock the form submission
|
||||
const mockOnSubmit = vi.fn();
|
||||
const mockOnBack = vi.fn();
|
||||
|
||||
describe('DexScreenerConnectForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('should render the form with all required fields', () => {
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
// Check for connector name input
|
||||
expect(screen.getByLabelText('Connector Name')).toBeInTheDocument();
|
||||
|
||||
// Check for benefits section
|
||||
expect(screen.getByText('No API Key Required')).toBeInTheDocument();
|
||||
|
||||
// Check for add token button
|
||||
expect(screen.getByRole('button', { name: /add token/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the DexScreener info alert', () => {
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
expect(screen.getByText('No API Key Required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/DexScreener API is public and free to use/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should accept valid connector name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
const nameInput = screen.getByLabelText('Connector Name');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Valid Name');
|
||||
|
||||
// Should accept valid name
|
||||
expect(nameInput).toHaveValue('Valid Name');
|
||||
});
|
||||
|
||||
it('should accept valid Ethereum address format', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
const addressInput = screen.getByLabelText('Token Address');
|
||||
const validAddress = '0x' + 'a'.repeat(40);
|
||||
await user.clear(addressInput);
|
||||
await user.type(addressInput, validAddress);
|
||||
|
||||
// Should accept valid address
|
||||
expect(addressInput).toHaveValue(validAddress);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Management', () => {
|
||||
it('should add a new token when Add Token button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
// Initially should have 1 token (default)
|
||||
expect(screen.getByText('Token #1')).toBeInTheDocument();
|
||||
|
||||
const addTokenButton = screen.getByRole('button', { name: /add token/i });
|
||||
await user.click(addTokenButton);
|
||||
|
||||
// Should now have 2 tokens
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Token #2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove a token when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
// Add a second token first
|
||||
const addTokenButton = screen.getByRole('button', { name: /add token/i });
|
||||
await user.click(addTokenButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Token #2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Remove the second token
|
||||
const removeButtons = screen.getAllByRole('button', { name: '' }); // X buttons have no text
|
||||
const lastRemoveButton = removeButtons[removeButtons.length - 1];
|
||||
await user.click(lastRemoveButton);
|
||||
|
||||
// Token #2 should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Token #2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow adding multiple tokens up to the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
const addTokenButton = screen.getByRole('button', { name: /add token/i });
|
||||
|
||||
// Add 2 more tokens (already have 1)
|
||||
await user.click(addTokenButton);
|
||||
await user.click(addTokenButton);
|
||||
|
||||
// Should have 3 tokens total
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Token #3')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 / 50 tokens')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable Add Token button when maximum tokens (50) are reached', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
const addTokenButton = screen.getByRole('button', { name: /add token/i });
|
||||
|
||||
// Add 49 more tokens (already have 1) - this is slow but necessary
|
||||
for (let i = 0; i < 49; i++) {
|
||||
await user.click(addTokenButton);
|
||||
}
|
||||
|
||||
// Button should be disabled and show max message
|
||||
await waitFor(() => {
|
||||
expect(addTokenButton).toBeDisabled();
|
||||
expect(screen.getByText(/maximum reached/i)).toBeInTheDocument();
|
||||
}, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chain Selection', () => {
|
||||
it('should display supported chains in the dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
// Click on the chain selector
|
||||
const chainSelect = screen.getByRole('combobox', { name: /chain/i });
|
||||
await user.click(chainSelect);
|
||||
|
||||
// Check for supported chains
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /ethereum/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /bsc/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /polygon/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow chain selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
const chainSelect = screen.getByRole('combobox', { name: /chain/i });
|
||||
await user.click(chainSelect);
|
||||
|
||||
// Select BSC
|
||||
const bscOption = screen.getByRole('option', { name: /bsc/i });
|
||||
await user.click(bscOption);
|
||||
|
||||
// Verify dropdown closed (option should not be visible anymore)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('option', { name: /bsc/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call onSubmit with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DexScreenerConnectForm onSubmit={mockOnSubmit} onBack={mockOnBack} isSubmitting={false} />);
|
||||
|
||||
// Fill connector name
|
||||
const nameInput = screen.getByLabelText('Connector Name');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'My Connector');
|
||||
|
||||
// Fill token address (first token already exists)
|
||||
const addressInput = screen.getByLabelText('Token Address');
|
||||
const validAddress = '0x' + 'a'.repeat(40);
|
||||
await user.type(addressInput, validAddress);
|
||||
|
||||
// Find and click submit button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const submitButton = buttons.find(btn => btn.textContent?.includes('Connect'));
|
||||
|
||||
if (submitButton) {
|
||||
await user.click(submitButton);
|
||||
|
||||
// Verify onSubmit was called
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,6 +38,31 @@ import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.ato
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
// Crypto Tool UI Components - Conversational Crypto Advisor
|
||||
import {
|
||||
TokenAnalysisToolUI,
|
||||
WatchlistDisplayToolUI,
|
||||
ActionConfirmationToolUI,
|
||||
AlertConfigurationToolUI,
|
||||
ProactiveAlertToolUI,
|
||||
TrendingTokensToolUI,
|
||||
WhaleActivityToolUI,
|
||||
MarketOverviewToolUI,
|
||||
HolderAnalysisToolUI,
|
||||
PortfolioDisplayToolUI,
|
||||
UserProfileToolUI,
|
||||
// Real-time crypto tools (Hybrid approach: RAG + Real-time)
|
||||
LiveTokenPriceToolUI,
|
||||
LiveTokenDataToolUI,
|
||||
} from "@/components/tool-ui/crypto";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesSync } from "@/hooks/use-messages-sync";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
|
@ -1570,6 +1595,28 @@ export default function NewChatPage() {
|
|||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<GeneratePodcastToolUI />
|
||||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
<SaveMemoryToolUI />
|
||||
<RecallMemoryToolUI />
|
||||
{/* Crypto Tool UI Components - Conversational Crypto Advisor */}
|
||||
<TokenAnalysisToolUI />
|
||||
<WatchlistDisplayToolUI />
|
||||
<ActionConfirmationToolUI />
|
||||
<AlertConfigurationToolUI />
|
||||
<ProactiveAlertToolUI />
|
||||
<TrendingTokensToolUI />
|
||||
<WhaleActivityToolUI />
|
||||
<MarketOverviewToolUI />
|
||||
<HolderAnalysisToolUI />
|
||||
<PortfolioDisplayToolUI />
|
||||
<UserProfileToolUI />
|
||||
{/* Real-time Crypto Tools - Hybrid approach (RAG + Real-time) */}
|
||||
<LiveTokenPriceToolUI />
|
||||
<LiveTokenDataToolUI />
|
||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info, Plus, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
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 { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
|
||||
// Token configuration schema
|
||||
const tokenSchema = z.object({
|
||||
chain: z.string().min(1, "Chain is required"),
|
||||
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token address (must be 0x followed by 40 hex characters)"),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
type TokenConfig = z.infer<typeof tokenSchema>;
|
||||
|
||||
// Form schema
|
||||
const dexScreenerFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
tokens: z.array(tokenSchema).min(1, "At least one token is required").max(50, "Maximum 50 tokens allowed"),
|
||||
});
|
||||
|
||||
type DexScreenerFormValues = z.infer<typeof dexScreenerFormSchema>;
|
||||
|
||||
// Supported chains
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "bsc", label: "BSC (Binance Smart Chain)" },
|
||||
{ value: "polygon", label: "Polygon" },
|
||||
{ value: "arbitrum", label: "Arbitrum" },
|
||||
{ value: "optimism", label: "Optimism" },
|
||||
{ value: "base", label: "Base" },
|
||||
{ value: "avalanche", label: "Avalanche" },
|
||||
{ value: "solana", label: "Solana" },
|
||||
] as const;
|
||||
|
||||
export const DexScreenerConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const [tokens, setTokens] = useState<TokenConfig[]>([
|
||||
{ chain: "ethereum", address: "", name: "" },
|
||||
]);
|
||||
|
||||
const form = useForm<DexScreenerFormValues>({
|
||||
resolver: zodResolver(dexScreenerFormSchema),
|
||||
defaultValues: {
|
||||
name: "DexScreener Connector",
|
||||
tokens: tokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync tokens state with form
|
||||
const updateFormTokens = (newTokens: TokenConfig[]) => {
|
||||
setTokens(newTokens);
|
||||
form.setValue("tokens", newTokens);
|
||||
};
|
||||
|
||||
const addToken = () => {
|
||||
if (tokens.length < 50) {
|
||||
updateFormTokens([...tokens, { chain: "ethereum", address: "", name: "" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToken = (index: number) => {
|
||||
if (tokens.length > 1) {
|
||||
updateFormTokens(tokens.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateToken = (index: number, field: keyof TokenConfig, value: string) => {
|
||||
const newTokens = [...tokens];
|
||||
newTokens[index] = { ...newTokens[index], [field]: value };
|
||||
updateFormTokens(newTokens);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: DexScreenerFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.DEXSCREENER_CONNECTOR,
|
||||
config: {
|
||||
tokens: values.tokens,
|
||||
},
|
||||
is_indexable: true,
|
||||
is_active: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">No API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
DexScreener API is public and free to use. Simply add the tokens you want to track.{" "}
|
||||
<a
|
||||
href="https://docs.dexscreener.com/api/reference"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
View API Documentation
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="dexscreener-connect-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4 sm:space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Crypto Tracker"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Token List */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm sm:text-base font-medium">Tracked Tokens</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tokens.length} / 50 tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-slate-400/20 bg-slate-400/5 dark:bg-white/5 p-3 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Token #{index + 1}
|
||||
</span>
|
||||
{tokens.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeToken(index)}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`chain-${index}`} className="text-xs sm:text-sm">
|
||||
Chain
|
||||
</Label>
|
||||
<Select
|
||||
value={token.chain}
|
||||
onValueChange={(value) => updateToken(index, "chain", value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`chain-${index}`}
|
||||
className="h-8 sm:h-10 bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
{SUPPORTED_CHAINS.map((chain) => (
|
||||
<SelectItem
|
||||
key={chain.value}
|
||||
value={chain.value}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{chain.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`address-${index}`} className="text-xs sm:text-sm">
|
||||
Token Address
|
||||
</Label>
|
||||
<Input
|
||||
id={`address-${index}`}
|
||||
placeholder="0x..."
|
||||
value={token.address}
|
||||
onChange={(e) => updateToken(index, "address", e.target.value)}
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`name-${index}`} className="text-xs sm:text-sm">
|
||||
Token Name (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id={`name-${index}`}
|
||||
placeholder="e.g., Wrapped Ether"
|
||||
value={token.name || ""}
|
||||
onChange={(e) => updateToken(index, "name", e.target.value)}
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addToken}
|
||||
disabled={tokens.length >= 50 || isSubmitting}
|
||||
className="w-full h-8 sm:h-10 text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
||||
Add Token {tokens.length >= 50 && "(Maximum reached)"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
allowFutureDates={true}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={periodicEnabled}
|
||||
onCheckedChange={setPeriodicEnabled}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">
|
||||
Sync Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={frequencyMinutes}
|
||||
onValueChange={setFrequencyMinutes}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="5" className="text-xs sm:text-sm">
|
||||
Every 5 minutes
|
||||
</SelectItem>
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">
|
||||
Every 15 minutes
|
||||
</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">
|
||||
Every hour
|
||||
</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">
|
||||
Every 6 hours
|
||||
</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">
|
||||
Every 12 hours
|
||||
</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">
|
||||
Daily
|
||||
</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">
|
||||
Weekly
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.DEXSCREENER_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with DexScreener integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.DEXSCREENER_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -111,6 +111,13 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
"Incremental sync - only changed files are re-indexed",
|
||||
"Full support for your vault's folder structure",
|
||||
],
|
||||
DEXSCREENER_CONNECTOR: [
|
||||
"Real-time cryptocurrency trading pair data from multiple DEXs",
|
||||
"Track token prices, volume, and liquidity across chains",
|
||||
"Search and analyze market data with AI-powered insights",
|
||||
"Monitor your crypto portfolio with automated updates",
|
||||
"Access historical price trends and trading volumes",
|
||||
],
|
||||
};
|
||||
|
||||
return benefits[connectorType] || null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import type { FC } from "react";
|
||||
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
|
||||
import { BookStackConnectForm } from "./components/bookstack-connect-form";
|
||||
import { CirclebackConnectForm } from "./components/circleback-connect-form";
|
||||
import { DexScreenerConnectForm } from "./components/dexscreener-connect-form";
|
||||
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
|
||||
import { GithubConnectForm } from "./components/github-connect-form";
|
||||
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
|
||||
import { LumaConnectForm } from "./components/luma-connect-form";
|
||||
import { MCPConnectForm } from "./components/mcp-connect-form";
|
||||
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
|
||||
import { SearxngConnectForm } from "./components/searxng-connect-form";
|
||||
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
|
||||
|
||||
export interface ConnectFormProps {
|
||||
onSubmit: (data: {
|
||||
|
|
@ -65,12 +77,33 @@ const componentCache = new Map<string, ConnectFormComponent>();
|
|||
* Factory function to get the appropriate connect form component for a connector type
|
||||
*/
|
||||
export function getConnectFormComponent(connectorType: string): ConnectFormComponent | null {
|
||||
const loader = formMap[connectorType];
|
||||
if (!loader) return null;
|
||||
|
||||
if (!componentCache.has(connectorType)) {
|
||||
componentCache.set(connectorType, dynamic(loader, { ssr: false }));
|
||||
switch (connectorType) {
|
||||
case "TAVILY_API":
|
||||
return TavilyApiConnectForm;
|
||||
case "SEARXNG_API":
|
||||
return SearxngConnectForm;
|
||||
case "LINKUP_API":
|
||||
return LinkupApiConnectForm;
|
||||
case "BAIDU_SEARCH_API":
|
||||
return BaiduSearchApiConnectForm;
|
||||
case "ELASTICSEARCH_CONNECTOR":
|
||||
return ElasticsearchConnectForm;
|
||||
case "BOOKSTACK_CONNECTOR":
|
||||
return BookStackConnectForm;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return GithubConnectForm;
|
||||
case "LUMA_CONNECTOR":
|
||||
return LumaConnectForm;
|
||||
case "CIRCLEBACK_CONNECTOR":
|
||||
return CirclebackConnectForm;
|
||||
case "DEXSCREENER_CONNECTOR":
|
||||
return DexScreenerConnectForm;
|
||||
case "MCP_CONNECTOR":
|
||||
return MCPConnectForm;
|
||||
case "OBSIDIAN_CONNECTOR":
|
||||
return ObsidianConnectForm;
|
||||
// Add other connector types here as needed
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return componentCache.get(connectorType)!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
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 type { ConnectorConfigProps } from "../index";
|
||||
|
||||
// Token configuration interface
|
||||
interface TokenConfig {
|
||||
chain: string;
|
||||
address: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Supported chains
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "bsc", label: "BSC (Binance Smart Chain)" },
|
||||
{ value: "polygon", label: "Polygon" },
|
||||
{ value: "arbitrum", label: "Arbitrum" },
|
||||
{ value: "optimism", label: "Optimism" },
|
||||
{ value: "base", label: "Base" },
|
||||
{ value: "avalanche", label: "Avalanche" },
|
||||
{ value: "solana", label: "Solana" },
|
||||
] as const;
|
||||
|
||||
export const DexScreenerConfig: FC<ConnectorConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [tokens, setTokens] = useState<TokenConfig[]>(
|
||||
(connector.config?.tokens as TokenConfig[]) || []
|
||||
);
|
||||
const [name, setName] = useState(connector.name || "");
|
||||
|
||||
const handleTokensChange = (newTokens: TokenConfig[]) => {
|
||||
setTokens(newTokens);
|
||||
onConfigChange?.({ ...connector.config, tokens: newTokens });
|
||||
};
|
||||
|
||||
const handleNameChange = (newName: string) => {
|
||||
setName(newName);
|
||||
onNameChange?.(newName);
|
||||
};
|
||||
|
||||
const addToken = () => {
|
||||
if (tokens.length < 50) {
|
||||
handleTokensChange([...tokens, { chain: "ethereum", address: "", name: "" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToken = (index: number) => {
|
||||
if (tokens.length > 1) {
|
||||
handleTokensChange(tokens.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateToken = (index: number, field: keyof TokenConfig, value: string) => {
|
||||
const newTokens = [...tokens];
|
||||
newTokens[index] = { ...newTokens[index], [field]: value };
|
||||
handleTokensChange(newTokens);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connector-name" className="text-sm font-medium">
|
||||
Connector Name
|
||||
</Label>
|
||||
<Input
|
||||
id="connector-name"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Crypto Tracker"
|
||||
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Token Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">Tracked Tokens</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tokens.length} / 50 tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-slate-400/20 bg-slate-400/5 dark:bg-white/5 p-3 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Token #{index + 1}
|
||||
</span>
|
||||
{tokens.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeToken(index)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`chain-${index}`} className="text-sm">
|
||||
Chain
|
||||
</Label>
|
||||
<Select
|
||||
value={token.chain}
|
||||
onValueChange={(value) => updateToken(index, "chain", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`chain-${index}`}
|
||||
className="h-10 bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
{SUPPORTED_CHAINS.map((chain) => (
|
||||
<SelectItem
|
||||
key={chain.value}
|
||||
value={chain.value}
|
||||
className="text-sm"
|
||||
>
|
||||
{chain.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`address-${index}`} className="text-sm">
|
||||
Token Address
|
||||
</Label>
|
||||
<Input
|
||||
id={`address-${index}`}
|
||||
placeholder="0x..."
|
||||
value={token.address}
|
||||
onChange={(e) => updateToken(index, "address", e.target.value)}
|
||||
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`name-${index}`} className="text-sm">
|
||||
Token Name (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id={`name-${index}`}
|
||||
placeholder="e.g., Wrapped Ether"
|
||||
value={token.name || ""}
|
||||
onChange={(e) => updateToken(index, "name", e.target.value)}
|
||||
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addToken}
|
||||
disabled={tokens.length >= 50}
|
||||
className="w-full h-10 text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Token {tokens.length >= 50 && "(Maximum reached)"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 p-4 space-y-2">
|
||||
<h4 className="text-sm font-medium">Configuration Tips</h4>
|
||||
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
|
||||
<li>Token addresses must be valid 40-character hex strings (0x...)</li>
|
||||
<li>You can track up to 50 tokens per connector</li>
|
||||
<li>Changes are saved automatically when you update the configuration</li>
|
||||
<li>Token names are optional but help identify tokens in search results</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,6 +3,29 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
||||
import { BookStackConfig } from "./components/bookstack-config";
|
||||
import { CirclebackConfig } from "./components/circleback-config";
|
||||
import { ClickUpConfig } from "./components/clickup-config";
|
||||
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
|
||||
import { ComposioDriveConfig } from "./components/composio-drive-config";
|
||||
import { ComposioGmailConfig } from "./components/composio-gmail-config";
|
||||
import { ConfluenceConfig } from "./components/confluence-config";
|
||||
import { DexScreenerConfig } from "./components/dexscreener-config";
|
||||
import { DiscordConfig } from "./components/discord-config";
|
||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||
import { GithubConfig } from "./components/github-config";
|
||||
import { GoogleDriveConfig } from "./components/google-drive-config";
|
||||
import { JiraConfig } from "./components/jira-config";
|
||||
import { LinkupApiConfig } from "./components/linkup-api-config";
|
||||
import { LumaConfig } from "./components/luma-config";
|
||||
import { MCPConfig } from "./components/mcp-config";
|
||||
import { ObsidianConfig } from "./components/obsidian-config";
|
||||
import { SearxngConfig } from "./components/searxng-config";
|
||||
import { SlackConfig } from "./components/slack-config";
|
||||
import { TavilyApiConfig } from "./components/tavily-api-config";
|
||||
import { TeamsConfig } from "./components/teams-config";
|
||||
import { WebcrawlerConfig } from "./components/webcrawler-config";
|
||||
|
||||
export interface ConnectorConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
|
|
@ -71,12 +94,55 @@ const componentCache = new Map<string, ConnectorConfigComponent>();
|
|||
export function getConnectorConfigComponent(
|
||||
connectorType: string
|
||||
): ConnectorConfigComponent | null {
|
||||
const loader = configMap[connectorType];
|
||||
if (!loader) return null;
|
||||
|
||||
if (!componentCache.has(connectorType)) {
|
||||
componentCache.set(connectorType, dynamic(loader, { ssr: false }));
|
||||
switch (connectorType) {
|
||||
case "GOOGLE_DRIVE_CONNECTOR":
|
||||
return GoogleDriveConfig;
|
||||
case "TAVILY_API":
|
||||
return TavilyApiConfig;
|
||||
case "SEARXNG_API":
|
||||
return SearxngConfig;
|
||||
case "LINKUP_API":
|
||||
return LinkupApiConfig;
|
||||
case "BAIDU_SEARCH_API":
|
||||
return BaiduSearchApiConfig;
|
||||
case "WEBCRAWLER_CONNECTOR":
|
||||
return WebcrawlerConfig;
|
||||
case "ELASTICSEARCH_CONNECTOR":
|
||||
return ElasticsearchConfig;
|
||||
case "SLACK_CONNECTOR":
|
||||
return SlackConfig;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return DiscordConfig;
|
||||
case "TEAMS_CONNECTOR":
|
||||
return TeamsConfig;
|
||||
case "CONFLUENCE_CONNECTOR":
|
||||
return ConfluenceConfig;
|
||||
case "BOOKSTACK_CONNECTOR":
|
||||
return BookStackConfig;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return GithubConfig;
|
||||
case "JIRA_CONNECTOR":
|
||||
return JiraConfig;
|
||||
case "CLICKUP_CONNECTOR":
|
||||
return ClickUpConfig;
|
||||
case "LUMA_CONNECTOR":
|
||||
return LumaConfig;
|
||||
case "CIRCLEBACK_CONNECTOR":
|
||||
return CirclebackConfig;
|
||||
case "DEXSCREENER_CONNECTOR":
|
||||
return DexScreenerConfig;
|
||||
case "MCP_CONNECTOR":
|
||||
return MCPConfig;
|
||||
case "OBSIDIAN_CONNECTOR":
|
||||
return ObsidianConfig;
|
||||
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||
return ComposioDriveConfig;
|
||||
case "COMPOSIO_GMAIL_CONNECTOR":
|
||||
return ComposioGmailConfig;
|
||||
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
||||
return ComposioCalendarConfig;
|
||||
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return componentCache.get(connectorType)!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,12 @@ export const OTHER_CONNECTORS = [
|
|||
description: "Search Luma events",
|
||||
connectorType: EnumConnectorName.LUMA_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "dexscreener-connector",
|
||||
title: "DexScreener",
|
||||
description: "Track cryptocurrency trading pairs across DEXs",
|
||||
connectorType: EnumConnectorName.DEXSCREENER_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "elasticsearch-connector",
|
||||
title: "Elasticsearch",
|
||||
|
|
|
|||
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 { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||
import { AlertTriangle, Coins, Inbox, LogOut, Megaphone, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -373,6 +373,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
badge:
|
||||
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Crypto",
|
||||
url: `/dashboard/${searchSpaceId}/crypto`,
|
||||
icon: Coins,
|
||||
isActive: pathname?.includes("/crypto"),
|
||||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[
|
||||
|
|
@ -382,6 +388,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
totalUnreadCount,
|
||||
isAnnouncementsSidebarOpen,
|
||||
announcementUnreadCount,
|
||||
searchSpaceId,
|
||||
pathname,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
139
surfsense_web/components/tool-ui/crypto/index.ts
Normal file
139
surfsense_web/components/tool-ui/crypto/index.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Crypto Tool UI Components
|
||||
*
|
||||
* These components render rich UI for crypto-related AI tools in the chat interface.
|
||||
* They follow the conversational UX paradigm where all crypto features are
|
||||
* AI-callable tools that render inline in the chat.
|
||||
*/
|
||||
|
||||
// Token Analysis - displays comprehensive token analysis
|
||||
export {
|
||||
TokenAnalysisToolUI,
|
||||
TokenAnalysisArgsSchema,
|
||||
TokenAnalysisResultSchema,
|
||||
type TokenAnalysisArgs,
|
||||
type TokenAnalysisResult,
|
||||
} from "./token-analysis";
|
||||
|
||||
// Watchlist Display - shows user's watchlist inline
|
||||
export {
|
||||
WatchlistDisplayToolUI,
|
||||
WatchlistDisplayArgsSchema,
|
||||
WatchlistDisplayResultSchema,
|
||||
type WatchlistDisplayArgs,
|
||||
type WatchlistDisplayResult,
|
||||
} from "./watchlist-display";
|
||||
|
||||
// Action Confirmation - confirms executed actions
|
||||
export {
|
||||
ActionConfirmationToolUI,
|
||||
ActionConfirmationArgsSchema,
|
||||
ActionConfirmationResultSchema,
|
||||
type ActionConfirmationArgs,
|
||||
type ActionConfirmationResult,
|
||||
} from "./action-confirmation";
|
||||
|
||||
// Alert Configuration - displays/edits alert settings
|
||||
export {
|
||||
AlertConfigurationToolUI,
|
||||
AlertConfigurationArgsSchema,
|
||||
AlertConfigurationResultSchema,
|
||||
type AlertConfigurationArgs,
|
||||
type AlertConfigurationResult,
|
||||
} from "./alert-configuration";
|
||||
|
||||
// Proactive Alert - AI-initiated alerts
|
||||
export {
|
||||
ProactiveAlertToolUI,
|
||||
ProactiveAlertArgsSchema,
|
||||
ProactiveAlertResultSchema,
|
||||
type ProactiveAlertArgs,
|
||||
type ProactiveAlertResult,
|
||||
} from "./proactive-alert";
|
||||
|
||||
// Trending Tokens - displays hot/trending tokens
|
||||
export {
|
||||
TrendingTokensToolUI,
|
||||
TrendingTokensArgsSchema,
|
||||
TrendingTokensResultSchema,
|
||||
type TrendingTokensArgs,
|
||||
type TrendingTokensResult,
|
||||
} from "./trending-tokens";
|
||||
|
||||
// Whale Activity - displays whale transactions
|
||||
export {
|
||||
WhaleActivityToolUI,
|
||||
WhaleActivityArgsSchema,
|
||||
WhaleActivityResultSchema,
|
||||
type WhaleActivityArgs,
|
||||
type WhaleActivityResult,
|
||||
} from "./whale-activity";
|
||||
|
||||
// Market Overview - displays market summary
|
||||
export {
|
||||
MarketOverviewToolUI,
|
||||
MarketOverviewArgsSchema,
|
||||
MarketOverviewResultSchema,
|
||||
type MarketOverviewArgs,
|
||||
type MarketOverviewResult,
|
||||
} from "./market-overview-tool";
|
||||
|
||||
// Holder Analysis - displays holder distribution
|
||||
export {
|
||||
HolderAnalysisToolUI,
|
||||
HolderAnalysisArgsSchema,
|
||||
HolderAnalysisResultSchema,
|
||||
type HolderAnalysisArgs,
|
||||
type HolderAnalysisResult,
|
||||
} from "./holder-analysis";
|
||||
|
||||
// Portfolio Display - displays user's portfolio
|
||||
export {
|
||||
PortfolioDisplayToolUI,
|
||||
PortfolioDisplayArgsSchema,
|
||||
PortfolioDisplayResultSchema,
|
||||
type PortfolioDisplayArgs,
|
||||
type PortfolioDisplayResult,
|
||||
} from "./portfolio-display";
|
||||
|
||||
// User Profile - displays user's investment profile
|
||||
export {
|
||||
UserProfileToolUI,
|
||||
UserProfileArgsSchema,
|
||||
UserProfileResultSchema,
|
||||
type UserProfileArgs,
|
||||
type UserProfileResult,
|
||||
} from "./user-profile";
|
||||
|
||||
// =========================================================================
|
||||
// REAL-TIME CRYPTO TOOLS - Hybrid approach (RAG + Real-time)
|
||||
// =========================================================================
|
||||
// These components render results from real-time DexScreener API calls.
|
||||
// Used alongside RAG-based tools for comprehensive crypto analysis.
|
||||
|
||||
// Live Token Price - displays real-time price from DexScreener
|
||||
export {
|
||||
LiveTokenPriceToolUI,
|
||||
LiveTokenPriceArgsSchema,
|
||||
LiveTokenPriceResultSchema,
|
||||
type LiveTokenPriceArgs,
|
||||
type LiveTokenPriceResult,
|
||||
} from "./live-token-price";
|
||||
|
||||
// Live Token Data - displays comprehensive real-time market data
|
||||
export {
|
||||
LiveTokenDataToolUI,
|
||||
LiveTokenDataArgsSchema,
|
||||
LiveTokenDataResultSchema,
|
||||
type LiveTokenDataArgs,
|
||||
type LiveTokenDataResult,
|
||||
} from "./live-token-data";
|
||||
|
||||
// Trading Suggestion - displays AI-powered entry/exit suggestions
|
||||
export {
|
||||
TradingSuggestionToolUI,
|
||||
TradingSuggestionArgsSchema,
|
||||
TradingSuggestionResultSchema,
|
||||
type TradingSuggestionArgs,
|
||||
type TradingSuggestionResult,
|
||||
} from "./trading-suggestion";
|
||||
246
surfsense_web/components/tool-ui/crypto/live-token-data.tsx
Normal file
246
surfsense_web/components/tool-ui/crypto/live-token-data.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, ExternalLink, Zap, RefreshCw, Activity, Droplets, BarChart3 } 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 live token data tool arguments
|
||||
export const LiveTokenDataArgsSchema = z.object({
|
||||
chain: z.string(),
|
||||
token_address: z.string(),
|
||||
token_symbol: z.string().optional(),
|
||||
include_all_pairs: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type LiveTokenDataArgs = z.infer<typeof LiveTokenDataArgsSchema>;
|
||||
|
||||
// Schema for live token data result (matches backend response)
|
||||
export const LiveTokenDataResultSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("live_token_data"),
|
||||
chain: z.string(),
|
||||
token_address: z.string(),
|
||||
token_symbol: z.string().optional(),
|
||||
token_name: z.string().optional(),
|
||||
price_usd: z.string().optional(),
|
||||
price_native: z.string().optional(),
|
||||
price_change_5m: z.number().optional(),
|
||||
price_change_1h: z.number().optional(),
|
||||
price_change_6h: z.number().optional(),
|
||||
price_change_24h: z.number().optional(),
|
||||
volume_24h: z.number().optional(),
|
||||
volume_6h: z.number().optional(),
|
||||
volume_1h: z.number().optional(),
|
||||
liquidity_usd: z.number().optional(),
|
||||
market_cap: z.number().optional(),
|
||||
fdv: z.number().optional(),
|
||||
txns_24h_buys: z.number().optional(),
|
||||
txns_24h_sells: z.number().optional(),
|
||||
txns_6h_buys: z.number().optional(),
|
||||
txns_6h_sells: z.number().optional(),
|
||||
txns_1h_buys: z.number().optional(),
|
||||
txns_1h_sells: z.number().optional(),
|
||||
total_volume_24h_all_pairs: z.number().optional(),
|
||||
total_liquidity_all_pairs: z.number().optional(),
|
||||
total_buys_24h_all_pairs: z.number().optional(),
|
||||
total_sells_24h_all_pairs: z.number().optional(),
|
||||
dex: z.string().optional(),
|
||||
pair_url: z.string().optional(),
|
||||
total_pairs: z.number().optional(),
|
||||
data_source: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LiveTokenDataResult = z.infer<typeof LiveTokenDataResultSchema>;
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null || num === 0) return "N/A";
|
||||
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)}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null) return "0";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-sm font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenDataToolUI - Displays comprehensive real-time market data
|
||||
* Used when AI fetches detailed live market information
|
||||
*/
|
||||
export const LiveTokenDataToolUI = makeAssistantToolUI<LiveTokenDataArgs, LiveTokenDataResult>({
|
||||
toolName: "get_live_token_data",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const hasError = result?.error;
|
||||
|
||||
const handleOpenDexScreener = () => {
|
||||
if (result?.pair_url) {
|
||||
window.open(result.pair_url, "_blank");
|
||||
} else if (args.token_address) {
|
||||
window.open(`https://dexscreener.com/${args.chain}/${args.token_address}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const totalTxns24h = (result?.txns_24h_buys || 0) + (result?.txns_24h_sells || 0);
|
||||
const buyRatio = totalTxns24h > 0 ? ((result?.txns_24h_buys || 0) / totalTxns24h) * 100 : 50;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden border-purple-500/20">
|
||||
<CardHeader className="pb-3 bg-gradient-to-r from-purple-500/5 to-transparent">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-purple-500" />
|
||||
Live Market Data
|
||||
{isLoading && <Badge variant="secondary" className="animate-pulse">Fetching...</Badge>}
|
||||
{!isLoading && !hasError && (
|
||||
<Badge variant="outline" className="text-xs text-purple-500 border-purple-500/30">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Real-time
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
{hasError ? (
|
||||
<div className="text-red-500 text-sm p-3 bg-red-500/10 rounded-lg">
|
||||
⚠️ {result.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={result?.chain || args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-xl">
|
||||
{result?.token_symbol || args.token_symbol || "Token"}
|
||||
</span>
|
||||
{result?.token_name && (
|
||||
<span className="text-muted-foreground text-sm">{result.token_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-2xl">{formatPrice(result?.price_usd)}</span>
|
||||
{result?.price_change_24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
result.price_change_24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{result.price_change_24h >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{result.price_change_24h >= 0 ? "+" : ""}{result.price_change_24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded-lg">
|
||||
<PriceChange value={result?.price_change_5m} label="5m" />
|
||||
<PriceChange value={result?.price_change_1h} label="1h" />
|
||||
<PriceChange value={result?.price_change_6h} label="6h" />
|
||||
<PriceChange value={result?.price_change_24h} label="24h" />
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<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 flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" /> 24h Volume
|
||||
</p>
|
||||
<p className="font-medium">{formatLargeNumber(result?.volume_24h)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3" /> Liquidity
|
||||
</p>
|
||||
<p className="font-medium">{formatLargeNumber(result?.liquidity_usd)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(result?.market_cap)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">FDV</p>
|
||||
<p className="font-medium">{formatLargeNumber(result?.fdv)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Activity */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" /> 24h Transactions
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{ width: `${buyRatio}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-500">
|
||||
{formatNumber(result?.txns_24h_buys)} buys
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatNumber(totalTxns24h)} total
|
||||
</span>
|
||||
<span className="text-red-500">
|
||||
{formatNumber(result?.txns_24h_sells)} sells
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DEX Info & Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>DEX: {result?.dex || "Unknown"}</span>
|
||||
{result?.total_pairs && result.total_pairs > 1 && (
|
||||
<span className="ml-2">• {result.total_pairs} pairs</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
DexScreener
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
161
surfsense_web/components/tool-ui/crypto/live-token-price.tsx
Normal file
161
surfsense_web/components/tool-ui/crypto/live-token-price.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, ExternalLink, Zap, RefreshCw } 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 live token price tool arguments
|
||||
export const LiveTokenPriceArgsSchema = z.object({
|
||||
chain: z.string(),
|
||||
token_address: z.string(),
|
||||
token_symbol: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LiveTokenPriceArgs = z.infer<typeof LiveTokenPriceArgsSchema>;
|
||||
|
||||
// Schema for live token price result (matches backend response)
|
||||
export const LiveTokenPriceResultSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("live_token_price"),
|
||||
chain: z.string(),
|
||||
token_address: z.string(),
|
||||
token_symbol: z.string().optional(),
|
||||
token_name: z.string().optional(),
|
||||
price_usd: z.string().optional(),
|
||||
price_native: z.string().optional(),
|
||||
price_change_5m: z.number().optional(),
|
||||
price_change_1h: z.number().optional(),
|
||||
price_change_6h: z.number().optional(),
|
||||
price_change_24h: z.number().optional(),
|
||||
volume_24h: z.number().optional(),
|
||||
liquidity_usd: z.number().optional(),
|
||||
market_cap: z.number().optional(),
|
||||
fdv: z.number().optional(),
|
||||
dex: z.string().optional(),
|
||||
pair_url: z.string().optional(),
|
||||
total_pairs: z.number().optional(),
|
||||
data_source: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LiveTokenPriceResult = z.infer<typeof LiveTokenPriceResultSchema>;
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null) return "N/A";
|
||||
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)}`;
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-sm font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenPriceToolUI - Displays real-time token price from DexScreener
|
||||
* Used when AI fetches current/live price data
|
||||
*/
|
||||
export const LiveTokenPriceToolUI = makeAssistantToolUI<LiveTokenPriceArgs, LiveTokenPriceResult>({
|
||||
toolName: "get_live_token_price",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const hasError = result?.error;
|
||||
|
||||
const handleOpenDexScreener = () => {
|
||||
if (result?.pair_url) {
|
||||
window.open(result.pair_url, "_blank");
|
||||
} else if (args.token_address) {
|
||||
window.open(`https://dexscreener.com/${args.chain}/${args.token_address}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden border-blue-500/20">
|
||||
<CardHeader className="pb-3 bg-gradient-to-r from-blue-500/5 to-transparent">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-blue-500" />
|
||||
Live Price
|
||||
{isLoading && <Badge variant="secondary" className="animate-pulse">Fetching...</Badge>}
|
||||
{!isLoading && !hasError && (
|
||||
<Badge variant="outline" className="text-xs text-blue-500 border-blue-500/30">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Real-time
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
{hasError ? (
|
||||
<div className="text-red-500 text-sm p-3 bg-red-500/10 rounded-lg">
|
||||
⚠️ {result.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={result?.chain || args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-xl">
|
||||
{result?.token_symbol || args.token_symbol || "Token"}
|
||||
</span>
|
||||
{result?.token_name && (
|
||||
<span className="text-muted-foreground text-sm">{result.token_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-2xl">{formatPrice(result?.price_usd)}</span>
|
||||
{result?.price_change_24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
result.price_change_24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{result.price_change_24h >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{result.price_change_24h >= 0 ? "+" : ""}{result.price_change_24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded-lg">
|
||||
<PriceChange value={result?.price_change_5m} label="5m" />
|
||||
<PriceChange value={result?.price_change_1h} label="1h" />
|
||||
<PriceChange value={result?.price_change_6h} label="6h" />
|
||||
<PriceChange value={result?.price_change_24h} label="24h" />
|
||||
</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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
236
surfsense_web/components/tool-ui/crypto/trading-suggestion.tsx
Normal file
236
surfsense_web/components/tool-ui/crypto/trading-suggestion.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Target, AlertCircle, Info, TrendingUp, TrendingDown, 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 { useState } from "react";
|
||||
|
||||
// Schema for trading suggestion tool arguments
|
||||
export const TradingSuggestionArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
tokenName: z.string().optional(),
|
||||
chain: z.string(),
|
||||
contractAddress: z.string().optional(),
|
||||
currentPrice: z.number(),
|
||||
entry: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
reasoning: z.string(),
|
||||
}),
|
||||
targets: z.array(z.object({
|
||||
level: z.number(),
|
||||
price: z.number(),
|
||||
percentGain: z.number(),
|
||||
confidence: z.number(),
|
||||
})),
|
||||
stopLoss: z.object({
|
||||
price: z.number(),
|
||||
percentLoss: z.number(),
|
||||
reasoning: z.string(),
|
||||
}),
|
||||
riskReward: z.number(),
|
||||
overallConfidence: z.number(),
|
||||
reasoning: z.array(z.string()),
|
||||
invalidationConditions: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TradingSuggestionArgs = z.infer<typeof TradingSuggestionArgsSchema>;
|
||||
|
||||
// Schema for trading suggestion result
|
||||
export const TradingSuggestionResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
alertsSet: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TradingSuggestionResult = z.infer<typeof TradingSuggestionResultSchema>;
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 0.01) return `$${price.toFixed(8)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const getRiskRewardColor = (ratio: number) => {
|
||||
if (ratio >= 3) return "text-green-500";
|
||||
if (ratio >= 2) return "text-yellow-500";
|
||||
return "text-red-500";
|
||||
};
|
||||
|
||||
const getRiskRewardLabel = (ratio: number) => {
|
||||
if (ratio >= 3) return "Excellent";
|
||||
if (ratio >= 2) return "Good";
|
||||
if (ratio >= 1.5) return "Fair";
|
||||
return "Poor";
|
||||
};
|
||||
|
||||
/**
|
||||
* TradingSuggestionToolUI - Displays AI-powered trading suggestions in chat
|
||||
* Used when AI responds to queries like "suggest entry for BONK" or "trading suggestion for SOL"
|
||||
*/
|
||||
export const TradingSuggestionToolUI = makeAssistantToolUI<TradingSuggestionArgs, TradingSuggestionResult>({
|
||||
toolName: "trading_suggestion",
|
||||
render: ({ args, result, status }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const isLoading = status.type === "running";
|
||||
|
||||
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 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Trading Suggestion
|
||||
{isLoading && <Badge variant="secondary" className="animate-pulse">Analyzing...</Badge>}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">Confidence</div>
|
||||
<div className="font-bold text-sm">{args.overallConfidence}%</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Token Header */}
|
||||
<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.tokenSymbol}</span>
|
||||
{args.tokenName && <span className="text-muted-foreground text-sm">{args.tokenName}</span>}
|
||||
</div>
|
||||
<span className="font-medium text-xl">{formatPrice(args.currentPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry Zone */}
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="font-semibold text-sm">Entry Zone</span>
|
||||
</div>
|
||||
<div className="font-bold text-lg text-green-600 dark:text-green-400 mb-1">
|
||||
{formatPrice(args.entry.min)} - {formatPrice(args.entry.max)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{args.entry.reasoning}</p>
|
||||
</div>
|
||||
|
||||
{/* Targets */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Take Profit Targets</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{args.targets.map((target) => (
|
||||
<div key={target.level} className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">🎯 T{target.level}</span>
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">{formatPrice(target.price)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-green-500 font-medium">+{target.percentGain.toFixed(1)}%</span>
|
||||
<Badge variant="outline" className="text-xs">{target.confidence}%</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop Loss */}
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="font-semibold text-sm">Stop Loss</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-bold text-lg text-red-600 dark:text-red-400">
|
||||
{formatPrice(args.stopLoss.price)}
|
||||
</span>
|
||||
<span className="text-sm text-red-500 font-medium">
|
||||
{args.stopLoss.percentLoss.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{args.stopLoss.reasoning}</p>
|
||||
</div>
|
||||
|
||||
{/* Risk/Reward */}
|
||||
<div className="p-3 bg-muted/50 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Risk/Reward Ratio</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={args.riskReward >= 3 ? "default" : args.riskReward >= 2 ? "secondary" : "destructive"}>
|
||||
{getRiskRewardLabel(args.riskReward)}
|
||||
</Badge>
|
||||
<span className={cn("font-bold text-lg", getRiskRewardColor(args.riskReward))}>
|
||||
1:{args.riskReward.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why? Section - Collapsible */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Why?</span>
|
||||
<span className={cn("ml-auto transition-transform text-xs", showDetails && "rotate-180")}>▼</span>
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="space-y-3 pl-6 text-sm">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Reasoning:</h4>
|
||||
<ul className="space-y-1">
|
||||
{args.reasoning.map((reason, i) => (
|
||||
<li key={i} className="text-xs flex items-start gap-2">
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>{reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Invalidation:</h4>
|
||||
<ul className="space-y-1">
|
||||
{args.invalidationConditions.map((condition, i) => (
|
||||
<li key={i} className="text-xs flex items-start gap-2">
|
||||
<AlertCircle className="h-3 w-3 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<span>{condition}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="default" size="sm" className="flex-1">
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Set Alerts
|
||||
</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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -59,3 +59,37 @@ export {
|
|||
} from "./user-memory";
|
||||
export { GenerateVideoPresentationToolUI } from "./video-presentation";
|
||||
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";
|
||||
|
|
|
|||
237
surfsense_web/content/docs/connectors/dexscreener.mdx
Normal file
237
surfsense_web/content/docs/connectors/dexscreener.mdx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
---
|
||||
title: DexScreener
|
||||
description: Connect DexScreener trading pair data to SurfSense
|
||||
---
|
||||
|
||||
# DexScreener Integration Setup Guide
|
||||
|
||||
DexScreener is a powerful cryptocurrency trading analytics platform that provides real-time data for trading pairs across multiple decentralized exchanges (DEXs). Integrate DexScreener with SurfSense to search and analyze crypto market data using AI.
|
||||
|
||||
## How it works
|
||||
|
||||
The DexScreener connector fetches trading pair data for tokens you specify and indexes it into your SurfSense search space. This allows you to:
|
||||
|
||||
- Track real-time price movements across multiple DEXs
|
||||
- Monitor trading volume and liquidity metrics
|
||||
- Analyze historical price trends
|
||||
- Search for specific tokens and trading pairs using natural language
|
||||
- Get AI-powered insights on market data
|
||||
|
||||
## Authorization
|
||||
|
||||
**No authentication required!** DexScreener's API is public and free to use. Simply configure the tokens you want to track and start indexing.
|
||||
|
||||
## Supported Chains
|
||||
|
||||
The DexScreener connector supports the following blockchain networks:
|
||||
|
||||
- **Ethereum** - The largest DeFi ecosystem
|
||||
- **BSC (Binance Smart Chain)** - High-speed, low-cost transactions
|
||||
- **Polygon** - Ethereum scaling solution
|
||||
- **Arbitrum** - Ethereum Layer 2 rollup
|
||||
- **Optimism** - Ethereum Layer 2 optimistic rollup
|
||||
- **Base** - Coinbase's Layer 2 network
|
||||
- **Avalanche** - High-throughput blockchain platform
|
||||
- **Solana** - High-performance blockchain
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Add the Connector
|
||||
|
||||
1. Navigate to your SurfSense dashboard
|
||||
2. Click **"Add Connector"** in the connector popup
|
||||
3. Select **"DexScreener"** from the connector list
|
||||
|
||||
### 2. Configure Connector Name
|
||||
|
||||
Enter a friendly name for your connector (e.g., "My Crypto Tracker", "DeFi Portfolio Monitor")
|
||||
|
||||
### 3. Add Tokens to Track
|
||||
|
||||
For each token you want to monitor:
|
||||
|
||||
1. Click **"Add Token"**
|
||||
2. Select the **blockchain network** from the dropdown
|
||||
3. Enter the **token contract address** (must be a valid 40-character hex address starting with `0x`)
|
||||
4. (Optional) Add a **friendly name** to help identify the token
|
||||
|
||||
**Example:**
|
||||
- Chain: Ethereum
|
||||
- Address: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2`
|
||||
- Name: Wrapped Ether (WETH)
|
||||
|
||||
You can track up to **50 tokens** per connector.
|
||||
|
||||
### 4. Configure Indexing Settings
|
||||
|
||||
#### Date Range (Optional)
|
||||
Set a date range to limit historical data indexing. Leave blank to index all available data.
|
||||
|
||||
#### Periodic Sync
|
||||
Enable automatic re-indexing to keep your data up-to-date:
|
||||
|
||||
- **Every 5 minutes** - For active trading monitoring
|
||||
- **Every 15 minutes** - For frequent updates
|
||||
- **Every hour** - For regular monitoring
|
||||
- **Every 6 hours** - For daily tracking
|
||||
- **Every 12 hours** - For bi-daily updates
|
||||
- **Daily** - For long-term portfolio tracking
|
||||
- **Weekly** - For occasional updates
|
||||
|
||||
### 5. Connect
|
||||
|
||||
Click **"Connect"** to create the connector and start indexing.
|
||||
|
||||
## Token Configuration
|
||||
|
||||
### Finding Token Addresses
|
||||
|
||||
Token contract addresses can be found on:
|
||||
|
||||
- **DexScreener**: Search for the token and copy the address from the URL or token info
|
||||
- **Etherscan** (Ethereum): etherscan.io
|
||||
- **BscScan** (BSC): bscscan.com
|
||||
- **PolygonScan** (Polygon): polygonscan.com
|
||||
- **Block explorers** for other chains
|
||||
|
||||
### Address Format
|
||||
|
||||
Token addresses must be:
|
||||
- Exactly 42 characters long
|
||||
- Start with `0x`
|
||||
- Followed by 40 hexadecimal characters (0-9, a-f, A-F)
|
||||
|
||||
**Valid example:** `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2`
|
||||
**Invalid examples:**
|
||||
- `C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` (missing 0x)
|
||||
- `0x123` (too short)
|
||||
- `0xGGGGaaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` (invalid hex characters)
|
||||
|
||||
## What Gets Indexed
|
||||
|
||||
For each tracked token, the connector indexes:
|
||||
|
||||
- **Token Information**
|
||||
- Token symbol and name
|
||||
- Contract address
|
||||
- Blockchain network
|
||||
|
||||
- **Price Data**
|
||||
- Current price in USD
|
||||
- Price in native currency
|
||||
- Price change percentages (5m, 1h, 6h, 24h)
|
||||
|
||||
- **Volume Metrics**
|
||||
- 24-hour trading volume
|
||||
- 6-hour trading volume
|
||||
- 1-hour trading volume
|
||||
|
||||
- **Liquidity Information**
|
||||
- Total liquidity in USD
|
||||
- Liquidity distribution across DEXs
|
||||
|
||||
- **DEX Information**
|
||||
- Exchange names
|
||||
- Trading pair details
|
||||
- Pool addresses
|
||||
|
||||
## Managing Your Connector
|
||||
|
||||
### Editing Configuration
|
||||
|
||||
1. Click **"Configure"** on your DexScreener connector
|
||||
2. Update the connector name if needed
|
||||
3. Add or remove tokens from the tracked list
|
||||
4. Modify token details (chain, address, name)
|
||||
5. Click **"Save"** to apply changes
|
||||
|
||||
### Triggering Manual Indexing
|
||||
|
||||
To manually re-index your connector:
|
||||
|
||||
1. Open the connector configuration
|
||||
2. Click **"Index Now"** or wait for the next scheduled sync
|
||||
|
||||
### Deleting the Connector
|
||||
|
||||
To remove the connector and all indexed data:
|
||||
|
||||
1. Click the **delete icon** on the connector card
|
||||
2. Confirm the deletion
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid token address" error
|
||||
|
||||
**Cause:** The token address doesn't match the required format.
|
||||
|
||||
**Solution:**
|
||||
- Ensure the address is exactly 42 characters (0x + 40 hex chars)
|
||||
- Verify you copied the full address without spaces
|
||||
- Check that the address contains only valid hex characters (0-9, a-f, A-F)
|
||||
|
||||
### "Token not found" error
|
||||
|
||||
**Cause:** The token doesn't exist on DexScreener or hasn't been indexed yet.
|
||||
|
||||
**Solution:**
|
||||
- Verify the token address is correct
|
||||
- Check that the token has trading activity on DEXs
|
||||
- Try searching for the token on [dexscreener.com](https://dexscreener.com) first
|
||||
- Ensure you selected the correct blockchain network
|
||||
|
||||
### No data appearing in search
|
||||
|
||||
**Cause:** Indexing may not have completed yet.
|
||||
|
||||
**Solution:**
|
||||
- Wait a few minutes for the initial indexing to complete
|
||||
- Check the connector status in your dashboard
|
||||
- Verify the connector is active and not paused
|
||||
- Try manually triggering a re-index
|
||||
|
||||
### "Maximum tokens exceeded" error
|
||||
|
||||
**Cause:** You're trying to add more than 50 tokens to a single connector.
|
||||
|
||||
**Solution:**
|
||||
- Remove some tokens you no longer need to track
|
||||
- Create a second DexScreener connector for additional tokens
|
||||
- Prioritize the most important tokens for your use case
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start Small**: Begin with 5-10 important tokens and expand as needed
|
||||
2. **Use Descriptive Names**: Add friendly names to tokens for easier identification
|
||||
3. **Set Appropriate Sync Frequency**: Balance data freshness with API usage
|
||||
4. **Organize by Strategy**: Create separate connectors for different trading strategies or portfolios
|
||||
5. **Regular Cleanup**: Remove tokens you're no longer tracking to keep data relevant
|
||||
|
||||
## API Rate Limits
|
||||
|
||||
DexScreener's public API has rate limits. To avoid issues:
|
||||
|
||||
- Don't track more tokens than you actively need
|
||||
- Use reasonable sync frequencies (avoid 5-minute intervals unless necessary)
|
||||
- If you encounter rate limit errors, increase the sync interval
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- **No Authentication**: DexScreener connector doesn't require API keys or credentials
|
||||
- **Public Data Only**: All indexed data is publicly available market data
|
||||
- **No Personal Info**: Token addresses and market data don't contain personal information
|
||||
- **Secure Storage**: All connector configurations are encrypted and stored securely
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- Check the [DexScreener API Documentation](https://docs.dexscreener.com/api/reference)
|
||||
- Visit the SurfSense community forums
|
||||
- Contact SurfSense support
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [DexScreener Website](https://dexscreener.com)
|
||||
- [DexScreener API Docs](https://docs.dexscreener.com/api/reference)
|
||||
- [SurfSense Connector Overview](/docs/connectors)
|
||||
|
|
@ -20,6 +20,7 @@ export enum EnumConnectorName {
|
|||
GOOGLE_DRIVE_CONNECTOR = "GOOGLE_DRIVE_CONNECTOR",
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR",
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR",
|
||||
DEXSCREENER_CONNECTOR = "DEXSCREENER_CONNECTOR",
|
||||
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR",
|
||||
WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR",
|
||||
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
|
|||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"COMPOSIO_GMAIL_CONNECTOR",
|
||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
"DEXSCREENER_CONNECTOR",
|
||||
]);
|
||||
|
||||
export const searchSourceConnector = z.object({
|
||||
|
|
|
|||
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";
|
||||
}
|
||||
|
||||
97
surfsense_web/nohup.out
Normal file
97
surfsense_web/nohup.out
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
|
||||
> surfsense_web@0.0.12 dev /Users/mac_1/Documents/GitHub/SurfSense/surfsense_web
|
||||
> next dev --turbopack -p 3999
|
||||
|
||||
▲ Next.js 16.1.0 (Turbopack)
|
||||
- Local: http://localhost:3999
|
||||
- Network: http://10.56.57.248:3999
|
||||
- Environments: .env
|
||||
|
||||
✓ Starting...
|
||||
[MDX] generated files in 21.16537500000004ms
|
||||
[MDX] started dev server
|
||||
✓ Ready in 2.3s
|
||||
GET / 200 in 1750ms (compile: 1438ms, render: 313ms)
|
||||
GET / 200 in 71ms (compile: 5ms, render: 66ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat 200 in 1979ms (compile: 1630ms, render: 349ms)
|
||||
Error: ENVIRONMENT_FALLBACK: There is no `timeZone` configured, this can lead to markup mismatches caused by environment differences. Consider adding a global default: https://next-intl.dev/docs/configuration#time-zone
|
||||
at LoginContent (app/(home)/login/page.tsx:17:27)
|
||||
15 |
|
||||
16 | function LoginContent() {
|
||||
> 17 | const t = useTranslations("auth");
|
||||
| ^
|
||||
18 | const tCommon = useTranslations("common");
|
||||
19 | const [authType, setAuthType] = useState<string | null>(null);
|
||||
20 | const [isLoading, setIsLoading] = useState(true); {
|
||||
code: 'ENVIRONMENT_FALLBACK',
|
||||
originalMessage: 'There is no `timeZone` configured, this can lead to markup mismatches caused by environment differences. Consider adding a global default: https://next-intl.dev/docs/configuration#time-zone'
|
||||
}
|
||||
GET /login 200 in 196ms (compile: 77ms, render: 119ms)
|
||||
GET / 200 in 83ms (compile: 7ms, render: 76ms)
|
||||
GET / 200 in 61ms (compile: 6ms, render: 55ms)
|
||||
GET /login 200 in 56ms (compile: 8ms, render: 48ms)
|
||||
GET / 200 in 90ms (compile: 21ms, render: 69ms)
|
||||
GET /login 200 in 53ms (compile: 20ms, render: 34ms)
|
||||
GET /login 200 in 79ms (compile: 8ms, render: 71ms)
|
||||
GET /auth/callback?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxOTUxYzAxMC00MzZmLTQ2MzYtODljYS01ZDg2ZjU3OTUxZGYiLCJhdWQiOlsiZmFzdGFwaS11c2VyczphdXRoIl0sImV4cCI6MTc3MDg3MDY3M30.O28yYMtWQuLeYdjEn2SpHGBSEzp8gzhyCnNTY4deQF4 200 in 59ms (compile: 32ms, render: 27ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat 200 in 64ms (compile: 11ms, render: 53ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat 200 in 60ms (compile: 11ms, render: 49ms)
|
||||
GET /dashboard/%7Bid%7D/crypto 200 in 568ms (compile: 544ms, render: 24ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat 200 in 33ms (compile: 11ms, render: 23ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat?query=Analyze+BULLA 200 in 25ms (compile: 7ms, render: 18ms)
|
||||
GET / 200 in 75ms (compile: 5ms, render: 70ms)
|
||||
GET /login 200 in 53ms (compile: 6ms, render: 47ms)
|
||||
GET /auth/callback?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxOTUxYzAxMC00MzZmLTQ2MzYtODljYS01ZDg2ZjU3OTUxZGYiLCJhdWQiOlsiZmFzdGFwaS11c2VyczphdXRoIl0sImV4cCI6MTc3MDg3MTQxMn0.HscRbBjnqK86Bppg8IFq5OMgxOJppOosPXKtFJjQ2X0 200 in 28ms (compile: 6ms, render: 22ms)
|
||||
GET /dashboard 200 in 233ms (compile: 28ms, render: 205ms)
|
||||
GET /dashboard/7/new-chat 200 in 33ms (compile: 9ms, render: 24ms)
|
||||
GET /dashboard/7/documents 200 in 784ms (compile: 757ms, render: 27ms)
|
||||
GET /dashboard/7/documents 200 in 21ms (compile: 7ms, render: 15ms)
|
||||
GET /dashboard/7/new-chat 200 in 61ms (compile: 11ms, render: 50ms)
|
||||
GET /dashboard/7/new-chat 200 in 32ms (compile: 10ms, render: 22ms)
|
||||
GET /dashboard/7/new-chat/19 200 in 29ms (compile: 8ms, render: 22ms)
|
||||
GET /dashboard/7/new-chat/19 200 in 42ms (compile: 16ms, render: 27ms)
|
||||
GET /dashboard/7/crypto 200 in 26ms (compile: 8ms, render: 18ms)
|
||||
GET /dashboard/7/crypto 200 in 31ms (compile: 13ms, render: 18ms)
|
||||
GET /dashboard/7/crypto 200 in 20ms (compile: 5ms, render: 14ms)
|
||||
GET /dashboard/7/crypto 200 in 16ms (compile: 3ms, render: 13ms)
|
||||
GET /dashboard/7/crypto 200 in 20ms (compile: 3ms, render: 17ms)
|
||||
GET /dashboard/7/new-chat/19 200 in 65ms (compile: 13ms, render: 52ms)
|
||||
GET /dashboard/7/documents 200 in 31ms (compile: 9ms, render: 22ms)
|
||||
GET /dashboard/7/documents 200 in 40ms (compile: 19ms, render: 21ms)
|
||||
GET /dashboard/7/crypto 200 in 28ms (compile: 8ms, render: 20ms)
|
||||
GET /dashboard/7/new-chat/19 200 in 32ms (compile: 10ms, render: 21ms)
|
||||
GET /dashboard/7/documents 200 in 28ms (compile: 10ms, render: 17ms)
|
||||
GET /dashboard/7/crypto 200 in 35ms (compile: 12ms, render: 22ms)
|
||||
GET /dashboard/7/logs 200 in 490ms (compile: 470ms, render: 20ms)
|
||||
GET /dashboard/7 200 in 458ms (compile: 412ms, render: 46ms)
|
||||
GET /dashboard/7/new-chat 200 in 35ms (compile: 8ms, render: 27ms)
|
||||
GET /dashboard/%7Bid%7D/new-chat?query=Analyze+BULLA 200 in 115ms (compile: 33ms, render: 82ms)
|
||||
GET / 200 in 76ms (compile: 7ms, render: 70ms)
|
||||
GET /dashboard/7/new-chat/19 200 in 104ms (compile: 13ms, render: 91ms)
|
||||
GET /login 200 in 55ms (compile: 6ms, render: 49ms)
|
||||
GET /dashboard/7/new-chat 200 in 57ms (compile: 18ms, render: 39ms)
|
||||
GET /login 200 in 51ms (compile: 4ms, render: 48ms)
|
||||
GET /login 200 in 87ms (compile: 34ms, render: 54ms)
|
||||
[MDX] closing dev server
|
||||
Downloading swc package @next/swc-wasm-nodejs... to /Users/mac_1/Library/Caches/next-swc
|
||||
npm warn Unknown env config "network-timeout". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "allow-build-scripts". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "_windsurf-registry". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "enable-pre-post-scripts". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "store-dir". This will stop working in the next major version of npm.
|
||||
npm warn Unknown env config "cache-dir". This will stop working in the next major version of npm.
|
||||
⚠ Attempted to load @next/swc-darwin-arm64, but an error occurred: dlopen(/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node, 0x0001): tried: '/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node' (code signature in <DEA08FB5-0F64-35DA-947E-EADD3218E146> '/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node' not valid for use in process: library load denied by system policy), '/System/Volumes/Preboot/Cryptexes/OS/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node' (no such file), '/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node' (code signature in <DEA08FB5-0F64-35DA-947E-EADD3218E146> '/Users/mac_1/Documents/GitHub/SurfSense/surfsense_web/node_modules/.pnpm/@next+swc-darwin-arm64@16.1.0/node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node' not valid for use in process: library load denied by system policy)
|
||||
⚠ TypeError: fetch failed
|
||||
at ignore-listed frames {
|
||||
[cause]: Error: getaddrinfo ENOTFOUND registry.npmjs.org
|
||||
at ignore-listed frames {
|
||||
errno: -3008,
|
||||
code: 'ENOTFOUND',
|
||||
syscall: 'getaddrinfo',
|
||||
hostname: 'registry.npmjs.org'
|
||||
}
|
||||
}
|
||||
⨯ Failed to load SWC binary for darwin/arm64, see more info here: https://nextjs.org/docs/messages/failed-loading-swc
|
||||
[?25h
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack -p 3999",
|
||||
"dev:turbo": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
|
|
@ -18,14 +18,26 @@
|
|||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"format:fix": "npx @biomejs/biome check --fix"
|
||||
"format:fix": "npx @biomejs/biome check --fix",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@ariakit/react": "^0.4.21",
|
||||
"@assistant-ui/react": "^0.12.19",
|
||||
"@assistant-ui/react-ai-sdk": "^1.3.3",
|
||||
"@assistant-ui/react-markdown": "^0.12.6",
|
||||
"@babel/standalone": "^7.29.2",
|
||||
"@blocknote/core": "^0.45.0",
|
||||
"@blocknote/mantine": "^0.45.0",
|
||||
"@blocknote/react": "^0.45.0",
|
||||
"@blocknote/server-util": "^0.45.0",
|
||||
"@electric-sql/client": "^1.4.0",
|
||||
"@electric-sql/pglite": "^0.3.14",
|
||||
"@electric-sql/pglite-sync": "^0.4.0",
|
||||
"@electric-sql/react": "^1.0.26",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@platejs/autoformat": "^52.0.11",
|
||||
|
|
@ -148,6 +160,9 @@
|
|||
"@svgr/webpack": "^8.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/babel__standalone": "^7.1.9",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/gapi": "^0.0.47",
|
||||
|
|
@ -156,13 +171,17 @@
|
|||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
12911
surfsense_web/pnpm-lock.yaml
generated
12911
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
19
surfsense_web/public/assets/connector-icons/dexscreener.svg
Normal file
19
surfsense_web/public/assets/connector-icons/dexscreener.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="12" cy="12" r="11" fill="url(#dexGradient)" stroke="currentColor" stroke-width="1"/>
|
||||
|
||||
<!-- Chart/Trading icon -->
|
||||
<path d="M7 14L10 11L13 14L17 8" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="14" r="1.5" fill="white"/>
|
||||
<circle cx="10" cy="11" r="1.5" fill="white"/>
|
||||
<circle cx="13" cy="14" r="1.5" fill="white"/>
|
||||
<circle cx="17" cy="8" r="1.5" fill="white"/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="dexGradient" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
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
|
||||
|
||||
29
surfsense_web/vitest.config.ts
Normal file
29
surfsense_web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.ts',
|
||||
css: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'vitest.setup.ts',
|
||||
'**/*.config.ts',
|
||||
'**/*.d.ts',
|
||||
'**/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
});
|
||||
43
surfsense_web/vitest.setup.ts
Normal file
43
surfsense_web/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock ResizeObserver (required for Radix UI components)
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect() { }
|
||||
};
|
||||
|
||||
// Mock PointerEvent APIs for Radix UI Select
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// Mock Next.js router
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: '/',
|
||||
query: {},
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock Next.js Image component
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: any) => {
|
||||
return React.createElement('img', props);
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue