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:
API Test Bot 2026-02-04 02:19:57 +07:00
parent ad795eb830
commit e4d020799b
58 changed files with 11315 additions and 661 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";