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,269 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
Bell,
TrendingUp,
TrendingDown,
Droplets,
Users,
Wallet,
X,
Check
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/routes/ui/dialog";
export type AlertType = "price_above" | "price_below" | "price_change" | "volume" | "whale" | "liquidity" | "holder_concentration";
export interface AlertConfig {
/** Alert type */
type: AlertType;
/** Threshold value */
threshold: number;
/** Whether alert is enabled */
enabled: boolean;
}
export interface AlertConfigModalProps {
/** Whether modal is open */
open: boolean;
/** Callback when modal is closed */
onOpenChange: (open: boolean) => void;
/** Token symbol */
tokenSymbol: string;
/** Current price for reference */
currentPrice?: string;
/** Existing alert configurations */
existingAlerts?: AlertConfig[];
/** Callback when alerts are saved */
onSave: (alerts: AlertConfig[]) => void;
}
const ALERT_TYPES: Array<{
type: AlertType;
label: string;
description: string;
icon: typeof Bell;
unit: string;
defaultThreshold: number;
}> = [
{
type: "price_above",
label: "Price Above",
description: "Alert when price rises above threshold",
icon: TrendingUp,
unit: "$",
defaultThreshold: 0,
},
{
type: "price_below",
label: "Price Below",
description: "Alert when price drops below threshold",
icon: TrendingDown,
unit: "$",
defaultThreshold: 0,
},
{
type: "price_change",
label: "Price Change",
description: "Alert on significant price movement",
icon: TrendingUp,
unit: "%",
defaultThreshold: 10,
},
{
type: "volume",
label: "Volume Spike",
description: "Alert on unusual trading volume",
icon: TrendingUp,
unit: "x",
defaultThreshold: 3,
},
{
type: "whale",
label: "Whale Activity",
description: "Alert on large transactions",
icon: Wallet,
unit: "$",
defaultThreshold: 10000,
},
{
type: "liquidity",
label: "Liquidity Change",
description: "Alert on liquidity pool changes",
icon: Droplets,
unit: "%",
defaultThreshold: 20,
},
{
type: "holder_concentration",
label: "Holder Concentration",
description: "Alert if top holders exceed threshold",
icon: Users,
unit: "%",
defaultThreshold: 50,
},
];
/**
* AlertConfigModal - Configure alerts for a token
*
* Features:
* - Multiple alert types (price, volume, whale, liquidity, holders)
* - Threshold configuration per alert type
* - Enable/disable individual alerts
* - Save all configurations at once
*/
export function AlertConfigModal({
open,
onOpenChange,
tokenSymbol,
currentPrice,
existingAlerts = [],
onSave,
}: AlertConfigModalProps) {
// Initialize alerts state from existing or defaults
const [alerts, setAlerts] = useState<AlertConfig[]>(() => {
return ALERT_TYPES.map(alertType => {
const existing = existingAlerts.find(a => a.type === alertType.type);
return existing || {
type: alertType.type,
threshold: alertType.defaultThreshold,
enabled: false,
};
});
});
const handleToggleAlert = (type: AlertType) => {
setAlerts(prev => prev.map(alert =>
alert.type === type
? { ...alert, enabled: !alert.enabled }
: alert
));
};
const handleThresholdChange = (type: AlertType, value: number) => {
setAlerts(prev => prev.map(alert =>
alert.type === type
? { ...alert, threshold: value }
: alert
));
};
const handleSave = () => {
onSave(alerts.filter(a => a.enabled));
onOpenChange(false);
};
const enabledCount = alerts.filter(a => a.enabled).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Configure Alerts for {tokenSymbol}
</DialogTitle>
{currentPrice && (
<p className="text-sm text-muted-foreground">
Current price: {currentPrice}
</p>
)}
</DialogHeader>
{/* Alert types list */}
<div className="flex-1 overflow-y-auto py-4 space-y-3">
{ALERT_TYPES.map((alertType) => {
const alert = alerts.find(a => a.type === alertType.type)!;
const Icon = alertType.icon;
return (
<div
key={alertType.type}
className={cn(
"rounded-lg border p-3 transition-colors",
alert.enabled ? "border-primary bg-primary/5" : "border-border"
)}
>
<div className="flex items-start gap-3">
{/* Toggle button */}
<button
onClick={() => handleToggleAlert(alertType.type)}
className={cn(
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors",
alert.enabled
? "bg-primary border-primary text-primary-foreground"
: "border-muted-foreground"
)}
>
{alert.enabled && <Check className="h-3 w-3" />}
</button>
{/* Alert info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{alertType.label}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{alertType.description}
</p>
{/* Threshold input (only when enabled) */}
{alert.enabled && (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">Threshold:</span>
<div className="flex items-center">
{alertType.unit === "$" && (
<span className="text-sm text-muted-foreground">$</span>
)}
<input
type="number"
value={alert.threshold}
onChange={(e) => handleThresholdChange(
alertType.type,
parseFloat(e.target.value) || 0
)}
className="w-24 px-2 py-1 text-sm border rounded bg-background"
min={0}
step={alertType.unit === "%" ? 1 : 0.01}
/>
{alertType.unit !== "$" && (
<span className="text-sm text-muted-foreground ml-1">
{alertType.unit}
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Footer with save button */}
<div className="flex items-center justify-between pt-4 border-t">
<p className="text-sm text-muted-foreground">
{enabledCount} alert{enabledCount !== 1 ? "s" : ""} enabled
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Alerts
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,256 @@
import { cn } from "~/lib/utils";
import {
Shield,
CheckCircle,
AlertTriangle,
XCircle,
ExternalLink,
Clock,
Star,
Bell
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { RiskBadge, getRiskLevelFromScore, type RiskLevel } from "../components/shared";
export interface SafetyFactor {
/** Category name (e.g., "Liquidity", "Contract", "Holders") */
category: string;
/** Status of this factor */
status: "positive" | "warning" | "danger";
/** Description of the finding */
description: string;
}
export interface SafetyScoreProps {
/** Safety score from 0-100 */
score: number;
/** Risk level (can be auto-calculated from score) */
level?: RiskLevel;
/** Individual safety factors */
factors: SafetyFactor[];
/** Data sources used for analysis */
sources?: string[];
/** When the analysis was performed */
timestamp?: Date;
/** Token symbol for display */
tokenSymbol?: string;
/** Callback when "Add to Watchlist" is clicked */
onAddToWatchlist?: () => void;
/** Callback when "Set Alert" is clicked */
onSetAlert?: () => void;
/** Whether token is already in watchlist */
isInWatchlist?: boolean;
/** Additional class names */
className?: string;
}
const STATUS_CONFIG = {
positive: {
icon: CheckCircle,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10",
},
warning: {
icon: AlertTriangle,
color: "text-yellow-600 dark:text-yellow-400",
bgColor: "bg-yellow-500/10",
},
danger: {
icon: XCircle,
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-500/10",
},
};
/**
* SafetyScoreDisplay - Comprehensive safety analysis visualization
*
* Features:
* - Visual score indicator (0-100)
* - Risk level badge
* - Categorized safety factors with status icons
* - Data sources attribution
* - Quick actions (Add to Watchlist, Set Alert)
*/
export function SafetyScoreDisplay({
score,
level,
factors,
sources = [],
timestamp,
tokenSymbol,
onAddToWatchlist,
onSetAlert,
isInWatchlist = false,
className,
}: SafetyScoreProps) {
const riskLevel = level || getRiskLevelFromScore(score);
// Group factors by status
const positiveFactors = factors.filter(f => f.status === "positive");
const warningFactors = factors.filter(f => f.status === "warning");
const dangerFactors = factors.filter(f => f.status === "danger");
// Calculate score color
const getScoreColor = () => {
if (score >= 80) return "text-green-500";
if (score >= 60) return "text-green-400";
if (score >= 40) return "text-yellow-500";
if (score >= 20) return "text-orange-500";
return "text-red-500";
};
// Calculate progress bar color
const getProgressColor = () => {
if (score >= 80) return "bg-green-500";
if (score >= 60) return "bg-green-400";
if (score >= 40) return "bg-yellow-500";
if (score >= 20) return "bg-orange-500";
return "bg-red-500";
};
return (
<div className={cn("rounded-lg border bg-card p-4", className)}>
{/* Header with score */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<Shield className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg">
Safety Analysis
{tokenSymbol && <span className="text-muted-foreground ml-1">({tokenSymbol})</span>}
</h3>
<RiskBadge level={riskLevel} score={score} showScore size="sm" />
</div>
</div>
{/* Large score display */}
<div className="text-right">
<div className={cn("text-3xl font-bold", getScoreColor())}>
{score}
</div>
<div className="text-xs text-muted-foreground">/ 100</div>
</div>
</div>
{/* Score progress bar */}
<div className="h-2 bg-muted rounded-full overflow-hidden mb-4">
<div
className={cn("h-full transition-all duration-500", getProgressColor())}
style={{ width: `${score}%` }}
/>
</div>
{/* Safety factors */}
<div className="space-y-3 mb-4">
{/* Danger factors first */}
{dangerFactors.length > 0 && (
<FactorSection title="🚨 Red Flags" factors={dangerFactors} status="danger" />
)}
{/* Warning factors */}
{warningFactors.length > 0 && (
<FactorSection title="⚠️ Warnings" factors={warningFactors} status="warning" />
)}
{/* Positive factors */}
{positiveFactors.length > 0 && (
<FactorSection title="✅ Positive Signals" factors={positiveFactors} status="positive" />
)}
</div>
{/* Action buttons */}
<div className="flex gap-2 mb-4">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onAddToWatchlist}
>
<Star className={cn("mr-1 h-4 w-4", isInWatchlist && "fill-yellow-500 text-yellow-500")} />
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onSetAlert}
>
<Bell className="mr-1 h-4 w-4" />
Set Alert
</Button>
</div>
{/* Footer with sources and timestamp */}
<div className="pt-3 border-t text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>
{timestamp
? `Analyzed ${timestamp.toLocaleTimeString()}`
: "Just now"
}
</span>
</div>
{sources.length > 0 && (
<div className="flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
<span>{sources.length} sources</span>
</div>
)}
</div>
{sources.length > 0 && (
<div className="mt-1 text-xs opacity-70">
Sources: {sources.join(", ")}
</div>
)}
</div>
</div>
);
}
/**
* FactorSection - Grouped display of safety factors
*/
function FactorSection({
title,
factors,
status
}: {
title: string;
factors: SafetyFactor[];
status: "positive" | "warning" | "danger";
}) {
const config = STATUS_CONFIG[status];
const Icon = config.icon;
return (
<div>
<h4 className="text-sm font-medium mb-2">{title}</h4>
<div className="space-y-1.5">
{factors.map((factor, index) => (
<div
key={index}
className={cn(
"flex items-start gap-2 p-2 rounded-md text-sm",
config.bgColor
)}
>
<Icon className={cn("h-4 w-4 mt-0.5 flex-shrink-0", config.color)} />
<div>
<span className="font-medium">{factor.category}:</span>{" "}
<span className="text-muted-foreground">{factor.description}</span>
</div>
</div>
))}
</div>
</div>
);
}
// Export types for use in other components
export type { SafetyFactor };

View file

@ -0,0 +1,322 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
Star,
Bell,
TrendingUp,
TrendingDown,
Plus,
Trash2,
ExternalLink,
AlertCircle
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface WatchlistToken {
/** Unique identifier */
id: string;
/** Token symbol */
symbol: string;
/** Token name */
name?: string;
/** Blockchain chain */
chain: string;
/** Contract address */
contractAddress: string;
/** Current price */
price: string;
/** 24h price change percentage */
priceChange24h: number;
/** Whether alerts are enabled for this token */
hasAlerts?: boolean;
/** Number of active alerts */
alertCount?: number;
}
export interface WatchlistAlert {
/** Alert ID */
id: string;
/** Token symbol */
tokenSymbol: string;
/** Alert type */
type: "price" | "volume" | "whale" | "liquidity";
/** Alert message */
message: string;
/** When the alert was triggered */
timestamp: Date;
/** Whether alert has been read */
isRead?: boolean;
}
export interface WatchlistPanelProps {
/** List of watched tokens */
tokens: WatchlistToken[];
/** Recent alerts */
recentAlerts?: WatchlistAlert[];
/** Callback when token is clicked */
onTokenClick?: (token: WatchlistToken) => void;
/** Callback when remove token is clicked */
onRemoveToken?: (tokenId: string) => void;
/** Callback when add token is clicked */
onAddToken?: () => void;
/** Callback when configure alerts is clicked */
onConfigureAlerts?: (token: WatchlistToken) => void;
/** Callback when alert is clicked */
onAlertClick?: (alert: WatchlistAlert) => void;
/** Additional class names */
className?: string;
}
/**
* WatchlistPanel - Token watchlist with alerts
*
* Features:
* - List of watched tokens with price changes
* - Alert indicators per token
* - Recent alerts section
* - Add/remove tokens
* - Quick access to alert configuration
*/
export function WatchlistPanel({
tokens,
recentAlerts = [],
onTokenClick,
onRemoveToken,
onAddToken,
onConfigureAlerts,
onAlertClick,
className,
}: WatchlistPanelProps) {
const [activeTab, setActiveTab] = useState<"tokens" | "alerts">("tokens");
const unreadAlerts = recentAlerts.filter(a => !a.isRead).length;
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500 fill-yellow-500" />
<h2 className="font-semibold">Watchlist</h2>
</div>
<Button size="sm" variant="outline" onClick={onAddToken}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{/* Tabs */}
<div className="flex border-b">
<button
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors",
activeTab === "tokens"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("tokens")}
>
Tokens ({tokens.length})
</button>
<button
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors relative",
activeTab === "alerts"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("alerts")}
>
Alerts
{unreadAlerts > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
{unreadAlerts}
</span>
)}
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === "tokens" ? (
<TokenList
tokens={tokens}
onTokenClick={onTokenClick}
onRemoveToken={onRemoveToken}
onConfigureAlerts={onConfigureAlerts}
/>
) : (
<AlertList
alerts={recentAlerts}
onAlertClick={onAlertClick}
/>
)}
</div>
</div>
);
}
/**
* TokenList - List of watched tokens
*/
function TokenList({
tokens,
onTokenClick,
onRemoveToken,
onConfigureAlerts,
}: {
tokens: WatchlistToken[];
onTokenClick?: (token: WatchlistToken) => void;
onRemoveToken?: (tokenId: string) => void;
onConfigureAlerts?: (token: WatchlistToken) => void;
}) {
if (tokens.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Star className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground text-sm">No tokens in watchlist</p>
<p className="text-muted-foreground text-xs mt-1">
Add tokens to track their price and set alerts
</p>
</div>
);
}
return (
<div className="divide-y">
{tokens.map((token) => (
<div
key={token.id}
className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer group"
onClick={() => onTokenClick?.(token)}
>
{/* Token info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{token.symbol}</span>
<ChainIcon chain={token.chain} size="sm" />
{token.hasAlerts && (
<Bell className="h-3 w-3 text-primary" />
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{token.name || token.contractAddress.slice(0, 10) + "..."}
</p>
</div>
{/* Price and change */}
<div className="text-right">
<p className="font-medium text-sm">{token.price}</p>
<p className={cn(
"text-xs 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>
{/* Actions (visible on hover) */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="p-1 hover:bg-muted rounded"
onClick={(e) => {
e.stopPropagation();
onConfigureAlerts?.(token);
}}
title="Configure alerts"
>
<Bell className="h-4 w-4 text-muted-foreground" />
</button>
<button
className="p-1 hover:bg-destructive/10 rounded"
onClick={(e) => {
e.stopPropagation();
onRemoveToken?.(token.id);
}}
title="Remove from watchlist"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</div>
))}
</div>
);
}
/**
* AlertList - List of recent alerts
*/
function AlertList({
alerts,
onAlertClick,
}: {
alerts: WatchlistAlert[];
onAlertClick?: (alert: WatchlistAlert) => void;
}) {
if (alerts.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Bell className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground text-sm">No alerts yet</p>
<p className="text-muted-foreground text-xs mt-1">
Configure alerts on your watched tokens
</p>
</div>
);
}
const getAlertIcon = (type: WatchlistAlert["type"]) => {
switch (type) {
case "price": return TrendingUp;
case "volume": return TrendingUp;
case "whale": return AlertCircle;
case "liquidity": return AlertCircle;
default: return Bell;
}
};
return (
<div className="divide-y">
{alerts.map((alert) => {
const Icon = getAlertIcon(alert.type);
return (
<div
key={alert.id}
className={cn(
"flex items-start gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer",
!alert.isRead && "bg-primary/5"
)}
onClick={() => onAlertClick?.(alert)}
>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
!alert.isRead ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{alert.tokenSymbol}</span>
<span className="text-xs text-muted-foreground capitalize">{alert.type}</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">{alert.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{alert.timestamp.toLocaleTimeString()}
</p>
</div>
{!alert.isRead && (
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0 mt-2" />
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,6 @@
// Crypto-specific components for SurfSense Browser Extension
export { SafetyScoreDisplay, type SafetyScoreProps, type SafetyFactor } from "./SafetyScoreDisplay";
export { WatchlistPanel, type WatchlistPanelProps, type WatchlistToken, type WatchlistAlert } from "./WatchlistPanel";
export { AlertConfigModal, type AlertConfigModalProps, type AlertConfig, type AlertType } from "./AlertConfigModal";