mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 17:26:23 +02:00
feat(crypto): add SurfSense 2.0 Crypto Co-Pilot UI components
Frontend - Web Dashboard: - Add crypto dashboard page with Watchlist, Alerts, Market, Profile tabs - Add 11 tool-ui components for inline chat display - Add crypto components (ChainIcon, SafetyBadge, PriceDisplay, etc.) - Add modals (AddTokenModal, CreateAlertModal) - Add mock data for development Frontend - Browser Extension: - Add shared components (ChainIcon, RiskBadge, PriceDisplay, SuggestionCard) - Add crypto components (SafetyScoreDisplay, WatchlistPanel, AlertConfigModal) - Add chat enhancements (WelcomeScreen, ThinkingStepsDisplay) - Add widget components for inline display - Enhance TokenInfoCard, ChatHeader, ChatInput, ChatInterface Documentation: - Add conversational UX specification - Add UX analysis report - Update extension UX design This implements the Conversational UX paradigm where crypto features are AI-callable tools that render inline in the chat interface.
This commit is contained in:
parent
ad795eb830
commit
e4d020799b
58 changed files with 11315 additions and 661 deletions
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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue