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

@ -1,24 +1,239 @@
import { Settings } from "lucide-react";
import { useState } from "react";
import {
Settings,
ChevronDown,
User,
LogOut,
ExternalLink,
Star,
Bell,
MessageSquare,
Plug
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { cn } from "~/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/routes/ui/popover";
export interface SearchSpace {
id: string;
name: string;
icon?: string;
}
export interface ChatHeaderProps {
/** Available search spaces */
searchSpaces?: SearchSpace[];
/** Currently selected search space */
selectedSpace?: SearchSpace;
/** Callback when search space is changed */
onSpaceChange?: (space: SearchSpace) => void;
/** User display name */
userName?: string;
/** User avatar URL */
userAvatar?: string;
/** Callback when logout is clicked */
onLogout?: () => void;
/** Callback when settings item is clicked */
onSettingsClick?: (item: string) => void;
}
/**
* Chat header with branding and settings
* Enhanced Chat header with branding, space selector, settings, and user menu
*
* Features:
* - Search space selector dropdown
* - Settings dropdown with full menu
* - User avatar with logout option
*/
export function ChatHeader() {
export function ChatHeader({
searchSpaces = [],
selectedSpace,
onSpaceChange,
userName,
userAvatar,
onLogout,
onSettingsClick,
}: ChatHeaderProps) {
const [spaceOpen, setSpaceOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const defaultSpaces: SearchSpace[] = [
{ id: "crypto", name: "Crypto", icon: "🪙" },
{ id: "general", name: "General", icon: "📚" },
{ id: "research", name: "Research", icon: "🔬" },
];
const spaces = searchSpaces.length > 0 ? searchSpaces : defaultSpaces;
const currentSpace = selectedSpace || spaces[0];
return (
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center justify-between p-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{/* Logo and brand */}
<div className="flex items-center gap-2">
<img
src="/assets/icon.png"
alt="SurfSense"
className="w-6 h-6"
/>
<h1 className="font-semibold text-lg">SurfSense</h1>
<h1 className="font-semibold text-base">SurfSense</h1>
</div>
<Button variant="ghost" size="icon">
<Settings className="h-4 w-4" />
</Button>
{/* Search Space Selector */}
<Popover open={spaceOpen} onOpenChange={setSpaceOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 px-2"
>
<span>{currentSpace.icon}</span>
<span className="max-w-[80px] truncate">{currentSpace.name}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="center">
<div className="space-y-0.5">
{spaces.map((space) => (
<button
key={space.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors",
currentSpace.id === space.id
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
onClick={() => {
onSpaceChange?.(space);
setSpaceOpen(false);
}}
>
<span>{space.icon}</span>
<span>{space.name}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Right side actions */}
<div className="flex items-center gap-1">
{/* Settings Dropdown */}
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="end">
<SettingsMenu
onItemClick={(item) => {
onSettingsClick?.(item);
setSettingsOpen(false);
}}
onLogout={onLogout}
/>
</PopoverContent>
</Popover>
{/* User Avatar */}
<UserAvatar
name={userName}
avatarUrl={userAvatar}
onLogout={onLogout}
/>
</div>
</div>
);
}
/**
* Settings menu items
*/
function SettingsMenu({
onItemClick,
onLogout,
}: {
onItemClick?: (item: string) => void;
onLogout?: () => void;
}) {
const menuItems = [
{ id: "connectors", label: "Manage Connectors", icon: Plug },
{ id: "chats", label: "View All Chats", icon: MessageSquare },
{ id: "watchlist", label: "Manage Watchlist", icon: Star },
{ id: "alerts", label: "Alert History", icon: Bell },
{ id: "settings", label: "Full Settings", icon: Settings, external: true },
];
return (
<div className="space-y-0.5">
{menuItems.map((item) => (
<button
key={item.id}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-muted transition-colors"
onClick={() => onItemClick?.(item.id)}
>
<item.icon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-left">{item.label}</span>
{item.external && <ExternalLink className="h-3 w-3 opacity-50" />}
</button>
))}
<div className="my-1 border-t" />
<button
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
onClick={onLogout}
>
<LogOut className="h-4 w-4" />
<span>Logout</span>
</button>
</div>
);
}
/**
* User avatar component
*/
function UserAvatar({
name,
avatarUrl,
onLogout,
}: {
name?: string;
avatarUrl?: string;
onLogout?: () => void;
}) {
const initials = name
? name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2)
: "U";
return (
<Popover>
<PopoverTrigger asChild>
<button className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/20 transition-all">
{avatarUrl ? (
<img src={avatarUrl} alt={name || "User"} className="w-full h-full object-cover" />
) : (
<span className="text-xs font-medium text-primary">{initials}</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2" align="end">
<div className="text-center pb-2 border-b mb-2">
<p className="font-medium text-sm">{name || "User"}</p>
</div>
<button
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
onClick={onLogout}
>
<LogOut className="h-4 w-4" />
<span>Logout</span>
</button>
</PopoverContent>
</Popover>
);
}

View file

@ -1,42 +1,256 @@
import { useState } from "react";
import { Send } from "lucide-react";
import { useState, useRef } from "react";
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { cn } from "~/lib/utils";
export interface AttachedFile {
/** File ID */
id: string;
/** File name */
name: string;
/** File type */
type: string;
/** File size in bytes */
size: number;
/** File object */
file: File;
}
interface ChatInputProps {
onSend: (content: string) => void;
/** Callback when message is sent */
onSend: (content: string, attachments?: AttachedFile[]) => void;
/** Whether input is disabled */
disabled?: boolean;
/** Placeholder text */
placeholder?: string;
/** Whether to show attachment button */
showAttachment?: boolean;
/** Accepted file types */
acceptedFileTypes?: string;
/** Max file size in bytes (default 10MB) */
maxFileSize?: number;
/** Quick action suggestions */
suggestions?: string[];
/** Callback when suggestion is clicked */
onSuggestionClick?: (suggestion: string) => void;
}
/**
* Chat input component with send button
* Enhanced chat input with attachment support and suggestions
*
* Features:
* - Text input with send button
* - File attachment button
* - Attached files preview
* - Quick action suggestions
* - Keyboard shortcuts (Enter to send)
*/
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
export function ChatInput({
onSend,
disabled,
placeholder,
showAttachment = true,
acceptedFileTypes = ".pdf,.txt,.md,.json,.csv,image/*",
maxFileSize = 10 * 1024 * 1024, // 10MB
suggestions = [],
onSuggestionClick,
}: ChatInputProps) {
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !disabled) {
onSend(input.trim());
if ((input.trim() || attachments.length > 0) && !disabled) {
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
setInput("");
setAttachments([]);
}
};
const handleFileSelect = (files: FileList | null) => {
if (!files) return;
const newAttachments: AttachedFile[] = [];
Array.from(files).forEach(file => {
// Check file size
if (file.size > maxFileSize) {
console.warn(`File ${file.name} exceeds max size of ${maxFileSize / 1024 / 1024}MB`);
return;
}
newAttachments.push({
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
type: file.type,
size: file.size,
file,
});
});
setAttachments(prev => [...prev, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments(prev => prev.filter(a => a.id !== id));
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFileSelect(e.dataTransfer.files);
};
const getFileIcon = (type: string) => {
if (type.startsWith("image/")) return Image;
if (type.includes("pdf") || type.includes("text")) return FileText;
return File;
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<form onSubmit={handleSubmit} className="border-t p-3">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={placeholder || "Type a message..."}
disabled={disabled}
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button type="submit" size="icon" disabled={disabled || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</form>
<div className="border-t">
{/* Quick suggestions */}
{suggestions.length > 0 && input.length === 0 && attachments.length === 0 && (
<div className="px-3 pt-2 flex gap-2 flex-wrap">
{suggestions.slice(0, 3).map((suggestion, index) => (
<button
key={index}
className="text-xs px-2 py-1 rounded-full bg-muted hover:bg-muted/80 text-muted-foreground transition-colors"
onClick={() => onSuggestionClick?.(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
{/* Attached files preview */}
{attachments.length > 0 && (
<div className="px-3 pt-2 flex gap-2 flex-wrap">
{attachments.map((attachment) => {
const FileIcon = getFileIcon(attachment.type);
return (
<div
key={attachment.id}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-sm group"
>
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="max-w-[100px] truncate">{attachment.name}</span>
<span className="text-xs text-muted-foreground">
({formatFileSize(attachment.size)})
</span>
<button
onClick={() => handleRemoveAttachment(attachment.id)}
className="ml-1 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
{/* Input form */}
<form
onSubmit={handleSubmit}
className={cn(
"p-3 transition-colors",
dragOver && "bg-primary/5"
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex items-end gap-2">
{/* Attachment button */}
{showAttachment && (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFileTypes}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
</>
)}
{/* Text input */}
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={placeholder || "Type a message..."}
disabled={disabled}
rows={1}
className={cn(
"w-full px-3 py-2 border rounded-md bg-background text-sm",
"focus:outline-none focus:ring-2 focus:ring-primary",
"resize-none min-h-[38px] max-h-[120px]",
"scrollbar-thin scrollbar-thumb-muted"
)}
style={{
height: "auto",
minHeight: "38px",
}}
/>
</div>
{/* Send button */}
<Button
type="submit"
size="icon"
className="h-9 w-9 flex-shrink-0"
disabled={disabled || (!input.trim() && attachments.length === 0)}
>
<Send className="h-4 w-4" />
</Button>
</div>
{/* Drag hint */}
{dragOver && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-md border-2 border-dashed border-primary pointer-events-none">
<p className="text-sm text-primary font-medium">Drop files here</p>
</div>
)}
</form>
</div>
);
}

View file

@ -2,78 +2,472 @@ import { useState } from "react";
import { usePageContext } from "../context/PageContextProvider";
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
import { QuickCapture } from "./QuickCapture";
import { ChatHeader } from "./ChatHeader";
import { ChatMessages } from "./ChatMessages";
import { ChatInput } from "./ChatInput";
import { ChatHeader, type SearchSpace } from "./ChatHeader";
import { ChatMessages, type Message, type MessageWidget } from "./ChatMessages";
import { ChatInput, type AttachedFile } from "./ChatInput";
import { ThinkingStepsDisplay, type ThinkingStep } from "./ThinkingStepsDisplay";
import {
MOCK_MODE,
MOCK_SEARCH_SPACES,
MOCK_WATCHLIST_TOKENS,
MOCK_WATCHLIST_ALERTS,
MOCK_SAFETY_SCORE,
MOCK_SAFETY_FACTORS,
MOCK_SAFETY_SOURCES,
} from "../mock/mockData";
import { SafetyScoreDisplay } from "../crypto/SafetyScoreDisplay";
import { WatchlistPanel } from "../crypto/WatchlistPanel";
import { AlertConfigModal } from "../crypto/AlertConfigModal";
import type { WatchlistItem } from "../widgets";
type ViewMode = "chat" | "watchlist" | "safety";
/**
* Natural language command patterns for conversational UX
*/
const COMMAND_PATTERNS = {
ADD_WATCHLIST: /add\s+(\w+)\s+to\s+(my\s+)?watchlist/i,
REMOVE_WATCHLIST: /remove\s+(\w+)\s+from\s+(my\s+)?watchlist/i,
SHOW_WATCHLIST: /(show|display|view)\s+(my\s+)?watchlist/i,
SET_ALERT: /set\s+alert\s+(if|when)\s+(\w+)\s+(drops?|pumps?|reaches?|changes?)\s+(\d+)%?/i,
ANALYZE_TOKEN: /(analyze|research|check)\s+(\w+)/i,
SAFETY_CHECK: /(is\s+)?(\w+)\s+(safe|risky|rug)/i,
};
/**
* Main chat interface for side panel
* Adapts UI based on page context (e.g., shows token card on DexScreener)
*
* Features:
* - Context-aware UI (DexScreener token detection)
* - Welcome screen for new users
* - Thinking steps visualization
* - File attachments support
* - Search space selection
* - Watchlist panel
* - Safety analysis view
*/
export function ChatInterface() {
const { context } = usePageContext();
const [messages, setMessages] = useState<any[]>([]);
const { context, isMockMode } = usePageContext();
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
const [selectedSpace, setSelectedSpace] = useState<SearchSpace>(
MOCK_SEARCH_SPACES[0]
);
const [viewMode, setViewMode] = useState<ViewMode>("chat");
const [showAlertModal, setShowAlertModal] = useState(false);
const [selectedTokenForAlert, setSelectedTokenForAlert] = useState<string | null>(null);
const [watchlistTokens, setWatchlistTokens] = useState(MOCK_WATCHLIST_TOKENS);
const [isInWatchlist, setIsInWatchlist] = useState(false);
const handleSendMessage = async (content: string) => {
// TODO: Implement message sending with backend API
console.log("Sending message:", content);
// Mock user data - in production, this would come from auth context
const userName = "Crypto Trader";
const handleSendMessage = async (content: string, attachments?: AttachedFile[]) => {
console.log("Sending message:", content, attachments);
setIsStreaming(true);
setViewMode("chat");
// Add user message
setMessages((prev) => [
...prev,
{
id: `msg-${Date.now()}`,
role: "user",
content,
timestamp: new Date(),
},
const userMessage: Message = {
id: `msg-${Date.now()}`,
role: "user",
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
// Simulate thinking steps
setThinkingSteps([
{ id: "1", type: "thinking", title: "Understanding your question...", isActive: true },
]);
// TODO: Stream response from backend
setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: `msg-${Date.now()}`,
role: "assistant",
content: "This is a placeholder response. Backend integration coming soon!",
timestamp: new Date(),
},
setThinkingSteps([
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
{ id: "2", type: "searching", title: "Searching knowledge base...", isActive: true },
]);
}, 500);
setTimeout(() => {
setThinkingSteps([
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
{ id: "2", type: "searching", title: "Searching knowledge base...", isComplete: true },
{ id: "3", type: "analyzing", title: "Analyzing results...", isActive: true },
]);
setIsStreaming(false);
}, 1000);
// Generate response based on content - with embedded widgets
setTimeout(() => {
setThinkingSteps([]);
let responseContent = "";
let widget: MessageWidget | undefined;
const tokenSymbol = context?.tokenData?.tokenSymbol || "BULLA";
// Check for natural language commands
const addWatchlistMatch = content.match(COMMAND_PATTERNS.ADD_WATCHLIST);
const showWatchlistMatch = content.match(COMMAND_PATTERNS.SHOW_WATCHLIST);
const setAlertMatch = content.match(COMMAND_PATTERNS.SET_ALERT);
if (addWatchlistMatch || content.toLowerCase().includes("add") && content.toLowerCase().includes("watchlist")) {
// Add to watchlist command
const token = addWatchlistMatch?.[1] || tokenSymbol;
responseContent = `Done! ✅\n\nI've added ${token} to your watchlist.`;
widget = {
type: "action_confirmation",
actionType: "watchlist_add",
tokenSymbol: token,
details: [
"Price change ±20%",
"Liquidity drop >10%",
"Whale movement >$50K",
],
};
// Actually add to watchlist
if (!watchlistTokens.find(t => t.symbol === token)) {
const newToken = {
id: `token-${Date.now()}`,
symbol: token,
name: token + " Token",
chain: context?.tokenData?.chain || "solana",
contractAddress: context?.tokenData?.pairAddress || "unknown",
price: context?.tokenData?.price || "$0.00001234",
priceChange24h: 156.7,
hasAlerts: true,
alertCount: 3,
};
setWatchlistTokens(prev => [...prev, newToken]);
setIsInWatchlist(true);
}
} else if (showWatchlistMatch || content.toLowerCase().includes("watchlist") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view"))) {
// Show watchlist command
responseContent = `Here's your watchlist:`;
const watchlistItems: WatchlistItem[] = watchlistTokens.map(t => ({
id: t.id,
symbol: t.symbol,
name: t.name,
chain: t.chain,
price: t.price,
priceChange24h: t.priceChange24h,
alertCount: t.alertCount,
}));
widget = {
type: "watchlist",
tokens: watchlistItems,
};
if (watchlistTokens.length > 0) {
const bestPerformer = watchlistTokens.reduce((a, b) =>
a.priceChange24h > b.priceChange24h ? a : b
);
responseContent += `\n\n${bestPerformer.symbol} is up ${bestPerformer.priceChange24h.toFixed(1)}% - your best performer! Want me to analyze if it's time to take profits?`;
}
} else if (setAlertMatch || content.toLowerCase().includes("alert") && (content.toLowerCase().includes("set") || content.toLowerCase().includes("notify"))) {
// Set alert command
const match = content.match(/(\d+)%/);
const percentage = match ? match[1] : "20";
const direction = content.toLowerCase().includes("drop") ? "drops" : "changes";
responseContent = `I'll set that up for you:`;
widget = {
type: "alert_config",
config: {
tokenSymbol: tokenSymbol,
condition: `Price ${direction} ${percentage}%`,
currentPrice: context?.tokenData?.price || "$0.00001234",
triggerPrice: "$0.00000987",
channels: {
browser: true,
inApp: true,
email: false,
},
},
isNew: true,
};
responseContent += `\n\nDone! I'll notify you if ${tokenSymbol} ${direction} ${percentage}% from current price. Want to set any other alerts?`;
} else if (content.toLowerCase().includes("safe") || content.toLowerCase().includes("rug") || content.toLowerCase().includes("analyze") || content.toLowerCase().includes("research")) {
// Token analysis with embedded widget
responseContent = `Here's my analysis of ${tokenSymbol}:`;
widget = {
type: "token_analysis",
data: {
symbol: tokenSymbol,
name: context?.tokenData?.tokenName || "Bulla Token",
chain: context?.tokenData?.chain || "solana",
price: context?.tokenData?.price || "$0.00001234",
priceChange24h: 156.7,
marketCap: "$2.1M",
volume24h: "$1.2M",
liquidity: "$450K",
safetyScore: MOCK_SAFETY_SCORE,
holderCount: 12456,
top10HolderPercent: 35,
},
isInWatchlist: isInWatchlist,
};
responseContent += `\n\nBased on your moderate risk profile, suggested allocation: 2-5% of portfolio. The safety score of ${MOCK_SAFETY_SCORE}/100 indicates medium risk - proceed with caution.`;
} else if (content.toLowerCase().includes("holder")) {
responseContent = `**Holder Analysis for ${tokenSymbol}:**
📊 **Distribution:**
- Total Holders: 12,456
- Top 10 Holders: 35% of supply
- Top 50 Holders: 52% of supply
🐋 **Whale Activity (24h):**
- 3 large buys (>$10K each)
- 1 large sell ($25K)
- Net flow: +$15K
**Concentration Risk:** Medium
The top holder owns 8.5% which is relatively high.`;
} else {
responseContent = `I can help you with crypto analysis! Try these commands:
**"Add BULLA to my watchlist"** - Track tokens
**"Show my watchlist"** - View tracked tokens
**"Set alert if BULLA drops 20%"** - Price alerts
**"Analyze BULLA"** - Full token analysis
**"Is BULLA safe?"** - Safety check
What would you like to know?`;
}
const assistantMessage: Message = {
id: `msg-${Date.now()}`,
role: "assistant",
content: responseContent,
timestamp: new Date(),
widget,
};
setMessages((prev) => [...prev, assistantMessage]);
setIsStreaming(false);
}, 1500);
};
const handleSuggestionClick = (text: string) => {
handleSendMessage(text);
};
const handleSpaceChange = (space: SearchSpace) => {
setSelectedSpace(space);
};
const handleSettingsClick = (item: string) => {
console.log("Settings item clicked:", item);
if (item === "watchlist") {
setViewMode("watchlist");
}
};
const handleLogout = () => {
console.log("Logout clicked");
};
const handleSafetyCheck = () => {
setViewMode("safety");
};
const handleAddToWatchlist = () => {
setIsInWatchlist(!isInWatchlist);
if (!isInWatchlist && context?.tokenData) {
const newToken = {
id: `token-${Date.now()}`,
symbol: context.tokenData.tokenSymbol || "TOKEN",
name: context.tokenData.tokenName || "Unknown Token",
chain: context.tokenData.chain,
contractAddress: context.tokenData.pairAddress,
price: context.tokenData.price || "$0",
priceChange24h: context.tokenData.priceChange24h || 0,
hasAlerts: false,
};
setWatchlistTokens(prev => [...prev, newToken]);
}
};
const handleConfigureAlerts = (tokenSymbol: string) => {
setSelectedTokenForAlert(tokenSymbol);
setShowAlertModal(true);
};
const handleRugCheck = () => {
handleSendMessage("Check this token for rug pull risks");
};
// Handle widget actions from embedded widgets in chat
const handleWidgetAction = (action: string, data?: unknown) => {
console.log("Widget action:", action, data);
switch (action) {
case "view_watchlist":
handleSendMessage("Show my watchlist");
break;
case "edit_alerts":
if (typeof data === "string") {
handleConfigureAlerts(data);
}
break;
case "analyze_token":
if (data && typeof data === "object" && "symbol" in data) {
handleSendMessage(`Analyze ${(data as { symbol: string }).symbol}`);
}
break;
case "remove_from_watchlist":
if (typeof data === "string") {
setWatchlistTokens(prev => prev.filter(t => t.id !== data));
}
break;
case "add_to_watchlist":
if (data && typeof data === "object" && "symbol" in data) {
handleSendMessage(`Add ${(data as { symbol: string }).symbol} to my watchlist`);
}
break;
case "set_alert":
if (typeof data === "string") {
handleConfigureAlerts(data);
}
break;
case "analyze_further":
if (data && typeof data === "object" && "symbol" in data) {
handleSendMessage(`Tell me more about ${(data as { symbol: string }).symbol} holders and whale activity`);
}
break;
case "tell_more":
if (data && typeof data === "object" && "tokenSymbol" in data) {
handleSendMessage(`Tell me more about ${(data as { tokenSymbol: string }).tokenSymbol}`);
}
break;
default:
console.log("Unhandled widget action:", action);
}
};
// Quick suggestions based on context
const quickSuggestions = context?.pageType === "dexscreener"
? ["Add to watchlist", "Is this safe?", "Set price alert"]
: ["Show my watchlist", "What's trending?", "Analyze BULLA"];
return (
<div className="flex flex-col h-full">
{/* Header */}
<ChatHeader />
{/* Header with space selector and settings */}
<ChatHeader
searchSpaces={MOCK_SEARCH_SPACES}
selectedSpace={selectedSpace}
onSpaceChange={handleSpaceChange}
userName={userName}
onSettingsClick={handleSettingsClick}
onLogout={handleLogout}
/>
{/* Token info card (only on DexScreener) */}
{context?.pageType === "dexscreener" && context.tokenData && (
<TokenInfoCard tokenData={context.tokenData} />
{context?.pageType === "dexscreener" && context.tokenData && viewMode === "chat" && (
<TokenInfoCard
tokenData={context.tokenData}
isInWatchlist={isInWatchlist}
onAddToWatchlist={handleAddToWatchlist}
onSafetyCheck={handleSafetyCheck}
onRugCheck={handleRugCheck}
/>
)}
{/* Chat messages */}
{/* Main content area */}
<div className="flex-1 overflow-y-auto">
<ChatMessages messages={messages} />
{viewMode === "chat" && (
<>
<ChatMessages
messages={messages}
onSuggestionClick={handleSuggestionClick}
userName={userName}
onWidgetAction={handleWidgetAction}
/>
{/* Thinking steps (shown during streaming) */}
{isStreaming && thinkingSteps.length > 0 && (
<div className="px-4 pb-4">
<ThinkingStepsDisplay
steps={thinkingSteps}
isThinking={isStreaming}
/>
</div>
)}
</>
)}
{viewMode === "watchlist" && (
<WatchlistPanel
tokens={watchlistTokens}
recentAlerts={MOCK_WATCHLIST_ALERTS}
onTokenClick={(token) => console.log("Token clicked:", token)}
onRemoveToken={(id) => setWatchlistTokens(prev => prev.filter(t => t.id !== id))}
onAddToken={() => console.log("Add token clicked")}
onConfigureAlerts={(token) => handleConfigureAlerts(token.symbol)}
onAlertClick={(alert) => console.log("Alert clicked:", alert)}
/>
)}
{viewMode === "safety" && (
<div className="p-4">
<SafetyScoreDisplay
score={MOCK_SAFETY_SCORE}
factors={MOCK_SAFETY_FACTORS}
sources={MOCK_SAFETY_SOURCES}
timestamp={new Date()}
tokenSymbol={context?.tokenData?.tokenSymbol}
isInWatchlist={isInWatchlist}
onAddToWatchlist={handleAddToWatchlist}
onSetAlert={() => handleConfigureAlerts(context?.tokenData?.tokenSymbol || "TOKEN")}
/>
<button
className="mt-4 text-sm text-primary hover:underline"
onClick={() => setViewMode("chat")}
>
Back to chat
</button>
</div>
)}
</div>
{/* Chat input */}
<ChatInput
onSend={handleSendMessage}
disabled={isStreaming}
placeholder={
context?.pageType === "dexscreener"
? "Ask about this token..."
: "Ask me anything..."
}
/>
{/* Chat input (only in chat mode) */}
{viewMode === "chat" && (
<ChatInput
onSend={handleSendMessage}
disabled={isStreaming}
placeholder={
context?.pageType === "dexscreener"
? `Ask about ${context.tokenData?.tokenSymbol || "this token"}...`
: "Ask me anything..."
}
suggestions={messages.length === 0 ? [] : quickSuggestions}
onSuggestionClick={handleSuggestionClick}
/>
)}
{/* Back to chat button for other views */}
{viewMode !== "chat" && (
<div className="border-t p-3">
<button
className="w-full py-2 text-sm text-center text-primary hover:bg-primary/5 rounded-md transition-colors"
onClick={() => setViewMode("chat")}
>
Back to Chat
</button>
</div>
)}
{/* Quick capture button */}
<QuickCapture />
{/* Alert configuration modal */}
<AlertConfigModal
open={showAlertModal}
onOpenChange={setShowAlertModal}
tokenSymbol={selectedTokenForAlert || "TOKEN"}
currentPrice={context?.tokenData?.price}
onSave={(alerts) => {
console.log("Alerts saved:", alerts);
setShowAlertModal(false);
}}
/>
</div>
);
}

View file

@ -1,31 +1,169 @@
import { WelcomeScreen } from "./WelcomeScreen";
import { cn } from "~/lib/utils";
import {
ActionConfirmationWidget,
ProactiveAlertCard,
WatchlistWidget,
AlertWidget,
TokenAnalysisWidget,
type ProactiveAlertData,
type WatchlistItem,
type AlertConfigData,
type TokenAnalysisData,
} from "../widgets";
// Widget types that can be embedded in messages
export type MessageWidget =
| { type: "action_confirmation"; actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete"; tokenSymbol: string; details?: string[] }
| { type: "proactive_alert"; alert: ProactiveAlertData; recommendation?: string }
| { type: "watchlist"; tokens: WatchlistItem[] }
| { type: "alert_config"; config: AlertConfigData; isNew?: boolean }
| { type: "token_analysis"; data: TokenAnalysisData; isInWatchlist?: boolean };
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: Date;
isStreaming?: boolean;
/** Embedded widget to display with this message */
widget?: MessageWidget;
}
export interface ChatMessagesProps {
messages: Message[];
onSuggestionClick?: (text: string) => void;
userName?: string;
/** Callbacks for widget interactions */
onWidgetAction?: (action: string, data?: unknown) => void;
}
/**
* Chat messages display component
* Chat messages display component with embedded widget support
* Shows WelcomeScreen when no messages, otherwise displays conversation
*
* Supports embedded widgets for conversational UX:
* - ActionConfirmationWidget: Shows action confirmations
* - ProactiveAlertCard: AI-initiated alerts
* - WatchlistWidget: Inline watchlist display
* - AlertWidget: Alert configuration display
* - TokenAnalysisWidget: Full token analysis
*/
export function ChatMessages({ messages }: { messages: any[] }) {
export function ChatMessages({
messages,
onSuggestionClick,
userName,
onWidgetAction,
}: ChatMessagesProps) {
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>Start a conversation...</p>
</div>
<WelcomeScreen
userName={userName}
onSuggestionClick={onSuggestionClick}
/>
);
}
const handleWidgetAction = (action: string, data?: unknown) => {
onWidgetAction?.(action, data);
};
const renderWidget = (widget: MessageWidget) => {
switch (widget.type) {
case "action_confirmation":
return (
<ActionConfirmationWidget
actionType={widget.actionType}
tokenSymbol={widget.tokenSymbol}
details={widget.details}
onViewWatchlist={() => handleWidgetAction("view_watchlist")}
onEditAlerts={() => handleWidgetAction("edit_alerts", widget.tokenSymbol)}
/>
);
case "proactive_alert":
return (
<ProactiveAlertCard
alert={widget.alert}
recommendation={widget.recommendation}
onViewDetails={() => handleWidgetAction("view_alert_details", widget.alert)}
onDismiss={() => handleWidgetAction("dismiss_alert", widget.alert.id)}
onSetAlert={() => handleWidgetAction("set_alert", widget.alert.tokenSymbol)}
onTellMore={() => handleWidgetAction("tell_more", widget.alert)}
/>
);
case "watchlist":
return (
<WatchlistWidget
tokens={widget.tokens}
onAnalyze={(token) => handleWidgetAction("analyze_token", token)}
onRemove={(id) => handleWidgetAction("remove_from_watchlist", id)}
onAddToken={() => handleWidgetAction("add_token")}
onClearAll={() => handleWidgetAction("clear_watchlist")}
/>
);
case "alert_config":
return (
<AlertWidget
config={widget.config}
isNew={widget.isNew}
onEdit={() => handleWidgetAction("edit_alert", widget.config)}
onDelete={() => handleWidgetAction("delete_alert", widget.config)}
onAddAnother={() => handleWidgetAction("add_another_alert")}
onViewAll={() => handleWidgetAction("view_all_alerts")}
/>
);
case "token_analysis":
return (
<TokenAnalysisWidget
data={widget.data}
isInWatchlist={widget.isInWatchlist}
onAddToWatchlist={() => handleWidgetAction("add_to_watchlist", widget.data)}
onSetAlert={() => handleWidgetAction("set_alert", widget.data.symbol)}
onAnalyzeFurther={() => handleWidgetAction("analyze_further", widget.data)}
/>
);
default:
return null;
}
};
return (
<div className="p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"
}`}
className={cn(
"flex flex-col",
message.role === "user" ? "items-end" : "items-start"
)}
>
{/* Message bubble */}
<div
className={`max-w-[80%] rounded-lg p-3 ${message.role === "user"
className={cn(
"max-w-[85%] rounded-lg p-3",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
: "bg-muted",
message.isStreaming && "animate-pulse"
)}
>
<p className="text-sm">{message.content}</p>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
{message.timestamp && (
<p className="text-xs opacity-60 mt-1">
{message.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</p>
)}
</div>
{/* Embedded widget (for assistant messages) */}
{message.role === "assistant" && message.widget && (
<div className="w-full max-w-[95%] mt-2">
{renderWidget(message.widget)}
</div>
)}
</div>
))}
</div>

View file

@ -0,0 +1,194 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
ChevronDown,
ChevronRight,
Brain,
Search,
FileText,
Lightbulb,
CheckCircle,
Loader2
} from "lucide-react";
export type ThinkingStepType = "thinking" | "searching" | "reading" | "analyzing" | "complete";
export interface ThinkingStep {
/** Step ID */
id: string;
/** Step type for icon selection */
type: ThinkingStepType;
/** Step title/label */
title: string;
/** Step description or content */
content?: string;
/** Whether step is currently active */
isActive?: boolean;
/** Whether step is complete */
isComplete?: boolean;
/** Timestamp */
timestamp?: Date;
}
export interface ThinkingStepsDisplayProps {
/** List of thinking steps */
steps: ThinkingStep[];
/** Whether AI is currently thinking */
isThinking?: boolean;
/** Whether to show expanded by default */
defaultExpanded?: boolean;
/** Additional class names */
className?: string;
}
const STEP_ICONS: Record<ThinkingStepType, typeof Brain> = {
thinking: Brain,
searching: Search,
reading: FileText,
analyzing: Lightbulb,
complete: CheckCircle,
};
const STEP_COLORS: Record<ThinkingStepType, string> = {
thinking: "text-purple-500",
searching: "text-blue-500",
reading: "text-green-500",
analyzing: "text-orange-500",
complete: "text-green-600",
};
/**
* ThinkingStepsDisplay - Shows AI reasoning process
*
* Features:
* - Collapsible thinking steps
* - Step-specific icons and colors
* - Active step animation
* - Expandable step details
*/
export function ThinkingStepsDisplay({
steps,
isThinking = false,
defaultExpanded = true,
className,
}: ThinkingStepsDisplayProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
if (steps.length === 0 && !isThinking) {
return null;
}
const activeStep = steps.find(s => s.isActive);
const completedSteps = steps.filter(s => s.isComplete).length;
return (
<div className={cn("rounded-lg border bg-muted/30", className)}>
{/* Header - clickable to expand/collapse */}
<button
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Brain className={cn(
"h-4 w-4",
isThinking ? "text-purple-500 animate-pulse" : "text-muted-foreground"
)} />
<span className="flex-1 text-sm font-medium">
{isThinking ? "Thinking..." : "Thought Process"}
</span>
<span className="text-xs text-muted-foreground">
{completedSteps}/{steps.length} steps
</span>
</button>
{/* Steps list */}
{isExpanded && (
<div className="px-3 pb-3 space-y-2">
{steps.map((step, index) => (
<StepItem key={step.id} step={step} index={index} />
))}
{/* Active thinking indicator */}
{isThinking && !activeStep && (
<div className="flex items-center gap-2 p-2 rounded-md bg-purple-500/10">
<Loader2 className="h-4 w-4 text-purple-500 animate-spin" />
<span className="text-sm text-purple-600 dark:text-purple-400">
Processing...
</span>
</div>
)}
</div>
)}
</div>
);
}
/**
* Individual step item
*/
function StepItem({ step, index }: { step: ThinkingStep; index: number }) {
const [isDetailExpanded, setIsDetailExpanded] = useState(false);
const Icon = STEP_ICONS[step.type];
const colorClass = STEP_COLORS[step.type];
return (
<div
className={cn(
"rounded-md transition-colors",
step.isActive && "bg-primary/5 ring-1 ring-primary/20",
step.isComplete && "opacity-80"
)}
>
<div
className="flex items-start gap-2 p-2 cursor-pointer"
onClick={() => step.content && setIsDetailExpanded(!isDetailExpanded)}
>
{/* Step number or icon */}
<div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0",
step.isActive ? "bg-primary/10" : "bg-muted"
)}>
{step.isActive ? (
<Loader2 className={cn("h-3 w-3 animate-spin", colorClass)} />
) : step.isComplete ? (
<CheckCircle className="h-3 w-3 text-green-500" />
) : (
<Icon className={cn("h-3 w-3", colorClass)} />
)}
</div>
{/* Step content */}
<div className="flex-1 min-w-0">
<p className={cn(
"text-sm",
step.isActive && "font-medium"
)}>
{step.title}
</p>
{/* Expandable detail */}
{step.content && isDetailExpanded && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
{step.content}
</p>
)}
</div>
{/* Expand indicator */}
{step.content && (
<ChevronRight className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
isDetailExpanded && "rotate-90"
)} />
)}
</div>
</div>
);
}

View file

@ -0,0 +1,113 @@
import { useMemo } from "react";
import { cn } from "~/lib/utils";
import { SuggestionCard, DEFAULT_CRYPTO_SUGGESTIONS } from "../components/shared";
export interface WelcomeScreenProps {
/** User's display name for personalized greeting */
userName?: string;
/** Callback when a suggestion is clicked */
onSuggestionClick?: (text: string) => void;
/** Custom suggestions (overrides defaults) */
suggestions?: Array<{ text: string; type: "general" | "safety" | "trending" | "wallet" | "custom" }>;
/** Additional class names */
className?: string;
}
/**
* Get time-based greeting message
*/
function getTimeBasedGreeting(userName?: string): string {
const hour = new Date().getHours();
// Greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning"];
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there"];
const eveningGreetings = ["Good evening", "Evening", "Hey there"];
const nightGreetings = ["Good night", "Evening", "Winding down"];
const lateNightGreetings = ["Still up?", "Night owl mode", "Burning the midnight oil"];
let greeting: string;
if (hour < 5) {
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
} else if (hour < 12) {
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
} else if (hour < 18) {
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
} else if (hour < 22) {
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
} else {
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
}
// Add personalization with name if available
if (userName) {
const firstName = userName.split(/\s+/)[0];
return `${greeting}, ${firstName}!`;
}
return `${greeting}!`;
}
/**
* WelcomeScreen - Displays greeting and suggestion cards for new conversations
*
* Features:
* - Time-based personalized greeting
* - Crypto-specific suggestion cards
* - Animated entrance
* - Accessible keyboard navigation
*/
export function WelcomeScreen({
userName,
onSuggestionClick,
suggestions = DEFAULT_CRYPTO_SUGGESTIONS,
className,
}: WelcomeScreenProps) {
// Memoize greeting so it doesn't change on re-renders
const greeting = useMemo(() => getTimeBasedGreeting(userName), [userName]);
return (
<div
className={cn(
"flex flex-col items-center justify-center h-full p-4",
"animate-in fade-in slide-in-from-bottom-4 duration-500",
className
)}
>
{/* Logo and Greeting */}
<div className="text-center mb-8">
<div className="text-5xl mb-4">🌊</div>
<h1 className="text-2xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-100">
{greeting}
</h1>
<p className="text-muted-foreground text-sm animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
Your AI co-pilot for crypto research and analysis
</p>
</div>
{/* Suggestion Cards */}
<div className="w-full max-w-sm space-y-2 animate-in fade-in slide-in-from-bottom-3 duration-500 delay-300">
<p className="text-xs text-muted-foreground mb-3 flex items-center gap-1">
<span>💡</span>
<span>Try asking:</span>
</p>
{suggestions.slice(0, 4).map((suggestion, index) => (
<SuggestionCard
key={index}
text={suggestion.text}
type={suggestion.type}
onClick={onSuggestionClick}
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
style={{ animationDelay: `${400 + index * 100}ms` } as React.CSSProperties}
/>
))}
</div>
{/* Footer hint */}
<p className="text-xs text-muted-foreground mt-6 animate-in fade-in duration-500 delay-700">
Press <kbd className="px-1.5 py-0.5 rounded bg-muted text-xs">K</kbd> for quick actions
</p>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { cn } from "~/lib/utils";
export type ChainType = "solana" | "ethereum" | "base" | "arbitrum" | "polygon" | "bsc" | "avalanche" | "unknown";
export interface ChainIconProps {
/** Blockchain chain identifier */
chain: ChainType | string;
/** Size of the icon */
size?: "sm" | "md" | "lg";
/** Show chain name label */
showLabel?: boolean;
/** Additional class names */
className?: string;
}
// Chain configuration with colors and display names
const CHAIN_CONFIG: Record<string, { color: string; bgColor: string; label: string; emoji: string }> = {
solana: {
color: "#9945FF",
bgColor: "bg-purple-500/10",
label: "Solana",
emoji: "◎",
},
ethereum: {
color: "#627EEA",
bgColor: "bg-blue-500/10",
label: "Ethereum",
emoji: "Ξ",
},
base: {
color: "#0052FF",
bgColor: "bg-blue-600/10",
label: "Base",
emoji: "🔵",
},
arbitrum: {
color: "#28A0F0",
bgColor: "bg-sky-500/10",
label: "Arbitrum",
emoji: "🔷",
},
polygon: {
color: "#8247E5",
bgColor: "bg-violet-500/10",
label: "Polygon",
emoji: "⬡",
},
bsc: {
color: "#F0B90B",
bgColor: "bg-yellow-500/10",
label: "BSC",
emoji: "🟡",
},
avalanche: {
color: "#E84142",
bgColor: "bg-red-500/10",
label: "Avalanche",
emoji: "🔺",
},
unknown: {
color: "#6B7280",
bgColor: "bg-gray-500/10",
label: "Unknown",
emoji: "🔗",
},
};
/**
* ChainIcon - Displays blockchain chain icon with optional label
*
* Features:
* - Chain-specific colors and icons
* - Multiple size variants
* - Optional chain name label
*/
export function ChainIcon({
chain,
size = "md",
showLabel = false,
className,
}: ChainIconProps) {
const normalizedChain = chain.toLowerCase();
const config = CHAIN_CONFIG[normalizedChain] || CHAIN_CONFIG.unknown;
const sizeClasses = {
sm: "w-4 h-4 text-xs",
md: "w-5 h-5 text-sm",
lg: "w-6 h-6 text-base",
};
const labelSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
return (
<div className={cn("flex items-center gap-1.5", className)}>
<div
className={cn(
"rounded-full flex items-center justify-center",
config.bgColor,
sizeClasses[size]
)}
style={{ color: config.color }}
title={config.label}
>
<span>{config.emoji}</span>
</div>
{showLabel && (
<span
className={cn("font-medium", labelSizes[size])}
style={{ color: config.color }}
>
{config.label}
</span>
)}
</div>
);
}
/**
* Get chain color for custom styling
*/
export function getChainColor(chain: string): string {
const normalizedChain = chain.toLowerCase();
return CHAIN_CONFIG[normalizedChain]?.color || CHAIN_CONFIG.unknown.color;
}

View file

@ -0,0 +1,94 @@
import { cn } from "~/lib/utils";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
export interface PriceDisplayProps {
/** Current price value */
price: string | number;
/** Price change percentage (positive = up, negative = down) */
priceChange?: number;
/** Show the change indicator arrow */
showChangeIndicator?: boolean;
/** Size variant */
size?: "sm" | "md" | "lg";
/** Additional class names */
className?: string;
}
/**
* PriceDisplay - Shows price with optional change indicator
*
* Features:
* - Color-coded price changes (green for up, red for down)
* - Animated arrow indicators
* - Multiple size variants
*/
export function PriceDisplay({
price,
priceChange,
showChangeIndicator = true,
size = "md",
className,
}: PriceDisplayProps) {
const isPositive = priceChange !== undefined && priceChange > 0;
const isNegative = priceChange !== undefined && priceChange < 0;
const isNeutral = priceChange === undefined || priceChange === 0;
const sizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-xl font-semibold",
};
const changeClasses = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
const iconSizes = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
const formatPrice = (value: string | number): string => {
if (typeof value === "string") return value;
if (value < 0.00001) return `$${value.toExponential(2)}`;
if (value < 1) return `$${value.toFixed(6)}`;
if (value < 1000) return `$${value.toFixed(2)}`;
return `$${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
};
const formatChange = (change: number): string => {
const sign = change > 0 ? "+" : "";
return `${sign}${change.toFixed(2)}%`;
};
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Price */}
<span className={cn("font-medium", sizeClasses[size])}>
{formatPrice(price)}
</span>
{/* Change indicator */}
{showChangeIndicator && priceChange !== undefined && (
<div
className={cn(
"flex items-center gap-0.5",
changeClasses[size],
isPositive && "text-green-500",
isNegative && "text-red-500",
isNeutral && "text-muted-foreground"
)}
>
{isPositive && <TrendingUp className={iconSizes[size]} />}
{isNegative && <TrendingDown className={iconSizes[size]} />}
{isNeutral && <Minus className={iconSizes[size]} />}
<span>{formatChange(priceChange)}</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,127 @@
import { cn } from "~/lib/utils";
import { Shield, AlertTriangle, XCircle, CheckCircle } from "lucide-react";
export type RiskLevel = "safe" | "low" | "medium" | "high" | "critical";
export interface RiskBadgeProps {
/** Risk level */
level: RiskLevel;
/** Optional score (0-100) */
score?: number;
/** Show score value */
showScore?: boolean;
/** Size variant */
size?: "sm" | "md" | "lg";
/** Additional class names */
className?: string;
}
// Risk level configuration
const RISK_CONFIG: Record<RiskLevel, {
label: string;
color: string;
bgColor: string;
borderColor: string;
Icon: typeof Shield;
}> = {
safe: {
label: "Safe",
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/30",
Icon: CheckCircle,
},
low: {
label: "Low Risk",
color: "text-green-500 dark:text-green-400",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/30",
Icon: Shield,
},
medium: {
label: "Medium",
color: "text-yellow-600 dark:text-yellow-400",
bgColor: "bg-yellow-500/10",
borderColor: "border-yellow-500/30",
Icon: AlertTriangle,
},
high: {
label: "High Risk",
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30",
Icon: AlertTriangle,
},
critical: {
label: "Critical",
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/30",
Icon: XCircle,
},
};
/**
* RiskBadge - Displays risk level with color-coded badge
*
* Features:
* - Color-coded risk levels (safe to critical)
* - Optional score display
* - Multiple size variants
* - Accessible with proper ARIA labels
*/
export function RiskBadge({
level,
score,
showScore = false,
size = "md",
className,
}: RiskBadgeProps) {
const config = RISK_CONFIG[level];
const { Icon } = config;
const sizeClasses = {
sm: "px-1.5 py-0.5 text-xs gap-1",
md: "px-2 py-1 text-sm gap-1.5",
lg: "px-3 py-1.5 text-base gap-2",
};
const iconSizes = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
return (
<div
className={cn(
"inline-flex items-center rounded-full border font-medium",
config.bgColor,
config.borderColor,
config.color,
sizeClasses[size],
className
)}
role="status"
aria-label={`Risk level: ${config.label}${score !== undefined ? `, Score: ${score}` : ""}`}
>
<Icon className={iconSizes[size]} />
<span>{config.label}</span>
{showScore && score !== undefined && (
<span className="font-bold">({score})</span>
)}
</div>
);
}
/**
* Get risk level from score (0-100)
*/
export function getRiskLevelFromScore(score: number): RiskLevel {
if (score >= 80) return "safe";
if (score >= 60) return "low";
if (score >= 40) return "medium";
if (score >= 20) return "high";
return "critical";
}

View file

@ -0,0 +1,110 @@
import { cn } from "~/lib/utils";
import { ArrowRight, Sparkles, TrendingUp, Shield, Wallet } from "lucide-react";
export type SuggestionType = "general" | "safety" | "trending" | "wallet" | "custom";
export interface SuggestionCardProps {
/** Suggestion text to display */
text: string;
/** Type of suggestion for icon selection */
type?: SuggestionType;
/** Custom icon (overrides type icon) */
icon?: React.ReactNode;
/** Click handler */
onClick?: (text: string) => void;
/** Disabled state */
disabled?: boolean;
/** Additional class names */
className?: string;
}
// Suggestion type icons
const TYPE_ICONS: Record<SuggestionType, typeof Sparkles> = {
general: Sparkles,
safety: Shield,
trending: TrendingUp,
wallet: Wallet,
custom: Sparkles,
};
/**
* SuggestionCard - Clickable suggestion card for chat prompts
*
* Features:
* - Type-specific icons
* - Hover animations
* - Click to send suggestion
* - Accessible keyboard navigation
*/
export function SuggestionCard({
text,
type = "general",
icon,
onClick,
disabled = false,
className,
}: SuggestionCardProps) {
const Icon = TYPE_ICONS[type];
const handleClick = () => {
if (!disabled && onClick) {
onClick(text);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
"group flex items-center gap-3 p-3 rounded-lg border",
"bg-card hover:bg-accent/50 transition-all duration-200",
"cursor-pointer select-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "opacity-50 cursor-not-allowed",
className
)}
aria-disabled={disabled}
>
{/* Icon */}
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary">
{icon || <Icon className="h-4 w-4" />}
</div>
{/* Text */}
<span className="flex-1 text-sm text-foreground line-clamp-2">
{text}
</span>
{/* Arrow indicator */}
<ArrowRight
className={cn(
"h-4 w-4 text-muted-foreground",
"opacity-0 -translate-x-2 transition-all duration-200",
"group-hover:opacity-100 group-hover:translate-x-0"
)}
/>
</div>
);
}
/**
* Default crypto-related suggestions
*/
export const DEFAULT_CRYPTO_SUGGESTIONS: Array<{ text: string; type: SuggestionType }> = [
{ text: "Is this token safe to invest in?", type: "safety" },
{ text: "What are the top gainers on Solana today?", type: "trending" },
{ text: "Analyze this wallet's trading history", type: "wallet" },
{ text: "Check for rug pull indicators", type: "safety" },
{ text: "What's the market sentiment for this token?", type: "general" },
];

View file

@ -0,0 +1,8 @@
// Shared components for SurfSense Browser Extension
// These components are reusable across the extension UI
export { PriceDisplay, type PriceDisplayProps } from "./PriceDisplay";
export { ChainIcon, getChainColor, type ChainIconProps, type ChainType } from "./ChainIcon";
export { RiskBadge, getRiskLevelFromScore, type RiskBadgeProps, type RiskLevel } from "./RiskBadge";
export { SuggestionCard, DEFAULT_CRYPTO_SUGGESTIONS, type SuggestionCardProps, type SuggestionType } from "./SuggestionCard";

View file

@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { MOCK_TOKEN_DATA, MOCK_MODE } from "../mock/mockData";
/**
* Page context types
@ -9,9 +10,12 @@ export interface TokenData {
chain: string;
pairAddress: string;
tokenSymbol?: string;
tokenName?: string;
price?: string;
priceChange24h?: number;
volume24h?: string;
liquidity?: string;
marketCap?: string;
}
export interface PageContext {
@ -24,11 +28,14 @@ export interface PageContext {
interface PageContextValue {
context: PageContext | null;
updateContext: (context: PageContext) => void;
/** Whether we're using mock data */
isMockMode: boolean;
}
const PageContextContext = createContext<PageContextValue>({
context: null,
updateContext: () => { },
isMockMode: false,
});
export function usePageContext() {
@ -38,11 +45,24 @@ export function usePageContext() {
/**
* Provider for page context detection
* Listens to messages from content scripts
* Uses mock data in development mode
*/
export function PageContextProvider({ children }: { children: ReactNode }) {
const [context, setContext] = useState<PageContext | null>(null);
const isMockMode = MOCK_MODE.enabled;
useEffect(() => {
// Use mock data in development mode
if (MOCK_MODE.enabled && MOCK_MODE.simulateDexScreener) {
setContext({
url: "https://dexscreener.com/solana/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
title: "BULLA / SOL | DEX Screener",
pageType: "dexscreener",
tokenData: MOCK_TOKEN_DATA,
});
return;
}
// Listen for page context updates from content script
const handleMessage = (message: any) => {
if (message.type === "PAGE_CONTEXT_UPDATE") {
@ -65,7 +85,7 @@ export function PageContextProvider({ children }: { children: ReactNode }) {
}, []);
return (
<PageContextContext.Provider value={{ context, updateContext: setContext }}>
<PageContextContext.Provider value={{ context, updateContext: setContext, isMockMode }}>
{children}
</PageContextContext.Provider>
);

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

View file

@ -1,59 +1,190 @@
import { useState } from "react";
import type { TokenData } from "../context/PageContextProvider";
import { Button } from "@/routes/ui/button";
import { TrendingUp, Shield, Users } from "lucide-react";
import { cn } from "~/lib/utils";
import {
TrendingUp,
TrendingDown,
Shield,
Users,
AlertTriangle,
Star,
Copy,
Check
} from "lucide-react";
import { ChainIcon } from "../components/shared/ChainIcon";
export type QuickActionType = "safety" | "holders" | "predict" | "rug";
export interface EnhancedTokenData extends TokenData {
priceChange24h?: number;
marketCap?: string;
}
interface TokenInfoCardProps {
tokenData: TokenData;
tokenData: EnhancedTokenData;
/** Whether token is in user's watchlist */
isInWatchlist?: boolean;
/** Callback when quick action button is clicked (generic handler) */
onQuickAction?: (action: QuickActionType, tokenData: EnhancedTokenData) => void;
/** Callback when watchlist button is clicked */
onToggleWatchlist?: (tokenData: EnhancedTokenData) => void;
/** Alternative: Direct callback for add to watchlist */
onAddToWatchlist?: () => void;
/** Alternative: Direct callback for safety check */
onSafetyCheck?: () => void;
/** Alternative: Direct callback for rug check */
onRugCheck?: () => void;
}
/**
* Token info card displayed when viewing DexScreener
* Shows quick token stats and action buttons
* TokenInfoCard - Enhanced token info card for DexScreener pages
*
* Features:
* - Price with 24h change indicator (/)
* - Market cap display
* - Add to watchlist button
* - 4 quick actions: Safety, Holders, Predict, Rug Check
* - Copy contract address
* - Chain-specific icon
*/
export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
const handleQuickAction = (action: string) => {
// TODO: Implement quick actions
console.log("Quick action:", action, tokenData);
export function TokenInfoCard({
tokenData,
isInWatchlist = false,
onQuickAction,
onToggleWatchlist,
onAddToWatchlist,
onSafetyCheck,
onRugCheck,
}: TokenInfoCardProps) {
const [copied, setCopied] = useState(false);
const handleQuickAction = (action: QuickActionType) => {
// Use specific callbacks if provided, otherwise fall back to generic handler
if (action === "safety" && onSafetyCheck) {
onSafetyCheck();
} else if (action === "rug" && onRugCheck) {
onRugCheck();
} else if (onQuickAction) {
onQuickAction(action, tokenData);
} else {
console.log("Quick action:", action, tokenData);
}
};
const handleCopyAddress = async () => {
try {
await navigator.clipboard.writeText(tokenData.pairAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy address:", err);
}
};
const handleToggleWatchlist = () => {
// Use specific callback if provided, otherwise fall back to generic handler
if (onAddToWatchlist) {
onAddToWatchlist();
} else if (onToggleWatchlist) {
onToggleWatchlist(tokenData);
}
};
const priceChange = tokenData.priceChange24h;
const isPositive = priceChange !== undefined && priceChange > 0;
const isNegative = priceChange !== undefined && priceChange < 0;
return (
<div className="border-b p-4 bg-muted/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
{/* Header with token info and watchlist */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="text-lg">🪙</span>
</div>
<div className="flex-1">
<h3 className="font-semibold">
{tokenData.tokenSymbol || "Unknown Token"}
</h3>
<p className="text-xs text-muted-foreground">
{tokenData.chain} {tokenData.pairAddress.slice(0, 8)}...
</p>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate">
{tokenData.tokenSymbol || "Unknown Token"}
</h3>
{/* Watchlist button */}
<button
onClick={handleToggleWatchlist}
className={cn(
"p-1 rounded-full transition-colors",
isInWatchlist
? "text-yellow-500 hover:text-yellow-600"
: "text-muted-foreground hover:text-foreground"
)}
title={isInWatchlist ? "Remove from watchlist" : "Add to watchlist"}
>
<Star className={cn("h-4 w-4", isInWatchlist && "fill-current")} />
</button>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ChainIcon chain={tokenData.chain} size="sm" />
<span></span>
<button
onClick={handleCopyAddress}
className="flex items-center gap-1 hover:text-foreground transition-colors"
title="Copy contract address"
>
<span>{tokenData.pairAddress.slice(0, 6)}...{tokenData.pairAddress.slice(-4)}</span>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
</div>
{/* Token stats */}
<div className="grid grid-cols-3 gap-2 mt-3 text-sm">
<div>
<span className="text-muted-foreground block text-xs">Price</span>
<p className="font-medium">{tokenData.price || "—"}</p>
</div>
{/* Price with change indicator */}
<div className="mt-3 flex items-baseline gap-2">
<span className="text-xl font-bold">{tokenData.price || "—"}</span>
{priceChange !== undefined && (
<span
className={cn(
"flex items-center gap-0.5 text-sm font-medium",
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" />}
{isPositive ? "+" : ""}{priceChange.toFixed(2)}%
</span>
)}
</div>
{/* Token stats grid - now with 4 columns including market cap */}
<div className="grid grid-cols-4 gap-2 mt-3 text-sm">
<div>
<span className="text-muted-foreground block text-xs">24h Vol</span>
<p className="font-medium">{tokenData.volume24h || "—"}</p>
<p className="font-medium truncate">{tokenData.volume24h || "—"}</p>
</div>
<div>
<span className="text-muted-foreground block text-xs">Liquidity</span>
<p className="font-medium">{tokenData.liquidity || "—"}</p>
<p className="font-medium truncate">{tokenData.liquidity || "—"}</p>
</div>
<div>
<span className="text-muted-foreground block text-xs">MCap</span>
<p className="font-medium truncate">{tokenData.marketCap || "—"}</p>
</div>
<div>
<span className="text-muted-foreground block text-xs">Chain</span>
<p className="font-medium capitalize truncate">{tokenData.chain}</p>
</div>
</div>
{/* Quick actions */}
<div className="flex gap-2 mt-3">
{/* Quick actions - now with 4 buttons */}
<div className="grid grid-cols-4 gap-1.5 mt-3">
<Button
size="sm"
variant="outline"
className="flex-1"
className="h-8 px-2 text-xs"
onClick={() => handleQuickAction("safety")}
>
<Shield className="mr-1 h-3 w-3" />
@ -62,7 +193,7 @@ export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
<Button
size="sm"
variant="outline"
className="flex-1"
className="h-8 px-2 text-xs"
onClick={() => handleQuickAction("holders")}
>
<Users className="mr-1 h-3 w-3" />
@ -71,12 +202,21 @@ export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => handleQuickAction("prediction")}
className="h-8 px-2 text-xs"
onClick={() => handleQuickAction("predict")}
>
<TrendingUp className="mr-1 h-3 w-3" />
Predict
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950"
onClick={() => handleQuickAction("rug")}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Rug
</Button>
</div>
</div>
);

View file

@ -0,0 +1,280 @@
/**
* Mock data for testing SurfSense Extension UI
* Remove or disable in production
*/
import type { TokenData } from "../context/PageContextProvider";
import type { WatchlistToken, WatchlistAlert } from "../crypto/WatchlistPanel";
import type { SafetyFactor } from "../crypto/SafetyScoreDisplay";
import type { AlertConfig } from "../crypto/AlertConfigModal";
// ============================================
// MOCK TOKEN DATA (DexScreener)
// ============================================
export const MOCK_TOKEN_DATA: TokenData & {
priceChange24h: number;
marketCap: string;
tokenName: string;
} = {
chain: "solana",
pairAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
tokenSymbol: "BULLA",
tokenName: "Bulla Token",
price: "$0.00001234",
priceChange24h: 156.7,
volume24h: "$1.2M",
liquidity: "$450K",
marketCap: "$2.1M",
};
export const MOCK_TOKEN_DATA_BEARISH: TokenData & {
priceChange24h: number;
marketCap: string;
tokenName: string;
} = {
chain: "ethereum",
pairAddress: "0x1234567890abcdef1234567890abcdef12345678",
tokenSymbol: "REKT",
tokenName: "Rekt Token",
price: "$0.00000042",
priceChange24h: -78.5,
volume24h: "$89K",
liquidity: "$12K",
marketCap: "$156K",
};
// ============================================
// MOCK SAFETY ANALYSIS
// ============================================
export const MOCK_SAFETY_SCORE = 72;
export const MOCK_SAFETY_FACTORS: SafetyFactor[] = [
// Positive factors
{
category: "Liquidity",
status: "positive",
description: "Liquidity pool is locked for 12 months",
},
{
category: "Contract",
status: "positive",
description: "Contract is verified on Solscan",
},
{
category: "Age",
status: "positive",
description: "Token has been active for 45 days",
},
// Warning factors
{
category: "Holders",
status: "warning",
description: "Top 10 holders own 35% of supply",
},
{
category: "Volume",
status: "warning",
description: "Trading volume decreased 40% in last 24h",
},
// Danger factors
{
category: "Mint Authority",
status: "danger",
description: "Mint authority is NOT revoked - tokens can be minted",
},
];
export const MOCK_SAFETY_SOURCES = [
"RugCheck.xyz",
"GoPlus Security",
"Solscan",
"DexScreener",
];
// ============================================
// MOCK WATCHLIST
// ============================================
export const MOCK_WATCHLIST_TOKENS: WatchlistToken[] = [
{
id: "1",
symbol: "BULLA",
name: "Bulla Token",
chain: "solana",
contractAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
price: "$0.00001234",
priceChange24h: 156.7,
hasAlerts: true,
alertCount: 2,
},
{
id: "2",
symbol: "BONK",
name: "Bonk",
chain: "solana",
contractAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
price: "$0.00002156",
priceChange24h: 12.3,
hasAlerts: true,
alertCount: 1,
},
{
id: "3",
symbol: "WIF",
name: "dogwifhat",
chain: "solana",
contractAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
price: "$2.45",
priceChange24h: -5.2,
hasAlerts: false,
},
{
id: "4",
symbol: "PEPE",
name: "Pepe",
chain: "ethereum",
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
price: "$0.00001089",
priceChange24h: 8.7,
hasAlerts: false,
},
{
id: "5",
symbol: "DEGEN",
name: "Degen",
chain: "base",
contractAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
price: "$0.0156",
priceChange24h: -15.3,
hasAlerts: true,
alertCount: 3,
},
];
export const MOCK_WATCHLIST_ALERTS: WatchlistAlert[] = [
{
id: "alert-1",
tokenSymbol: "BULLA",
type: "price",
message: "BULLA price increased above $0.00001 (+156%)",
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 mins ago
isRead: false,
},
{
id: "alert-2",
tokenSymbol: "BULLA",
type: "whale",
message: "Large transaction detected: 500M BULLA ($6,170) transferred",
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 mins ago
isRead: false,
},
{
id: "alert-3",
tokenSymbol: "DEGEN",
type: "volume",
message: "DEGEN volume spike: 5x average in last hour",
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 mins ago
isRead: true,
},
{
id: "alert-4",
tokenSymbol: "BONK",
type: "liquidity",
message: "BONK liquidity increased by 25% ($1.2M added)",
timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
isRead: true,
},
{
id: "alert-5",
tokenSymbol: "DEGEN",
type: "price",
message: "DEGEN dropped below $0.02 (-15%)",
timestamp: new Date(Date.now() - 1000 * 60 * 120), // 2 hours ago
isRead: true,
},
];
// ============================================
// MOCK ALERT CONFIGS
// ============================================
export const MOCK_ALERT_CONFIGS: AlertConfig[] = [
{ type: "price_above", threshold: 0.00002, enabled: true },
{ type: "price_below", threshold: 0.000005, enabled: true },
{ type: "price_change", threshold: 20, enabled: false },
{ type: "volume", threshold: 3, enabled: true },
{ type: "whale", threshold: 10000, enabled: false },
{ type: "liquidity", threshold: 30, enabled: false },
{ type: "holder_concentration", threshold: 50, enabled: false },
];
// ============================================
// MOCK SEARCH SPACES
// ============================================
export const MOCK_SEARCH_SPACES = [
{ id: "crypto", name: "Crypto", icon: "🪙" },
{ id: "general", name: "General", icon: "📚" },
{ id: "research", name: "Research", icon: "🔬" },
{ id: "defi", name: "DeFi", icon: "💰" },
{ id: "nft", name: "NFT", icon: "🖼️" },
];
// ============================================
// MOCK CHAT MESSAGES
// ============================================
export const MOCK_CHAT_MESSAGES = [
{
id: "msg-1",
role: "user" as const,
content: "Is BULLA token safe to invest?",
timestamp: new Date(Date.now() - 1000 * 60 * 5),
},
{
id: "msg-2",
role: "assistant" as const,
content: `Based on my analysis of BULLA token, here's what I found:
**Safety Score: 72/100** Medium Risk
** Positive Signals:**
- Liquidity is locked for 12 months
- Contract is verified
- Active for 45 days
** Warnings:**
- Top 10 holders own 35% of supply
- Volume decreased 40% recently
**🚨 Red Flags:**
- Mint authority is NOT revoked
**Recommendation:** Proceed with caution. The unlocked mint authority is a significant risk factor.`,
timestamp: new Date(Date.now() - 1000 * 60 * 4),
thinkingSteps: [
{ id: "1", type: "thinking" as const, title: "Understanding your question...", isComplete: true },
{ id: "2", type: "searching" as const, title: "Fetching token data from DexScreener...", isComplete: true },
{ id: "3", type: "analyzing" as const, title: "Running safety analysis...", isComplete: true },
{ id: "4", type: "complete" as const, title: "Analysis complete", isComplete: true },
],
},
];
// ============================================
// FEATURE FLAGS
// ============================================
export const MOCK_MODE = {
/** Enable mock data for development/testing */
enabled: true,
/** Simulate DexScreener page context */
simulateDexScreener: true,
/** Show mock watchlist data */
showWatchlist: true,
/** Show mock alerts */
showAlerts: true,
};

View file

@ -0,0 +1,125 @@
import { cn } from "~/lib/utils";
import { CheckCircle, Bell, Eye, Settings } from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface ActionConfirmationProps {
/** Type of action that was confirmed */
actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete";
/** Token symbol */
tokenSymbol: string;
/** Additional details about the action */
details?: string[];
/** Callback when view watchlist is clicked */
onViewWatchlist?: () => void;
/** Callback when edit alerts is clicked */
onEditAlerts?: () => void;
/** Additional class names */
className?: string;
}
/**
* ActionConfirmationWidget - Embedded widget showing action confirmation in chat
*
* Used when AI executes an action like adding to watchlist or setting alerts.
* Displays confirmation with relevant follow-up actions.
*/
export function ActionConfirmationWidget({
actionType,
tokenSymbol,
details = [],
onViewWatchlist,
onEditAlerts,
className,
}: ActionConfirmationProps) {
const getActionTitle = () => {
switch (actionType) {
case "watchlist_add":
return `${tokenSymbol} added to your watchlist`;
case "watchlist_remove":
return `${tokenSymbol} removed from watchlist`;
case "alert_set":
return `Alert configured for ${tokenSymbol}`;
case "alert_delete":
return `Alert removed for ${tokenSymbol}`;
default:
return "Action completed";
}
};
const getIcon = () => {
switch (actionType) {
case "watchlist_add":
case "watchlist_remove":
return Eye;
case "alert_set":
case "alert_delete":
return Bell;
default:
return CheckCircle;
}
};
const Icon = getIcon();
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-green-500" />
</div>
<span className="font-medium text-sm">Action Confirmed</span>
</div>
{/* Action details */}
<div className="bg-muted/50 rounded-md p-3 mb-3">
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">{getActionTitle()}</span>
</div>
{details.length > 0 && (
<div className="space-y-1 mt-2">
<p className="text-xs text-muted-foreground">Also set up:</p>
{details.map((detail, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<Bell className="h-3 w-3 text-primary" />
<span>{detail}</span>
</div>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex gap-2">
{(actionType === "watchlist_add" || actionType === "watchlist_remove") && (
<Button
size="sm"
variant="outline"
onClick={onViewWatchlist}
className="flex-1"
>
<Eye className="h-3 w-3 mr-1" />
View Watchlist
</Button>
)}
{(actionType === "watchlist_add" || actionType === "alert_set") && (
<Button
size="sm"
variant="outline"
onClick={onEditAlerts}
className="flex-1"
>
<Settings className="h-3 w-3 mr-1" />
Edit Alerts
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { cn } from "~/lib/utils";
import { Bell, CheckCircle, Edit, Trash2, Plus } from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface AlertConfigData {
/** Token symbol */
tokenSymbol: string;
/** Alert condition description */
condition: string;
/** Current price */
currentPrice?: string;
/** Trigger price */
triggerPrice?: string;
/** Notification channels */
channels: {
browser: boolean;
inApp: boolean;
email: boolean;
};
}
export interface AlertWidgetProps {
/** Alert configuration data */
config: AlertConfigData;
/** Whether this is a new alert or existing */
isNew?: boolean;
/** Callback when edit is clicked */
onEdit?: () => void;
/** Callback when delete is clicked */
onDelete?: () => void;
/** Callback when add another is clicked */
onAddAnother?: () => void;
/** Callback when view all alerts is clicked */
onViewAll?: () => void;
/** Additional class names */
className?: string;
}
/**
* AlertWidget - Embedded alert configuration widget for chat
*
* Shows alert configuration inline in chat after user sets an alert
* via natural language command.
*/
export function AlertWidget({
config,
isNew = true,
onEdit,
onDelete,
onAddAnother,
onViewAll,
className,
}: AlertWidgetProps) {
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{isNew ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Bell className="h-4 w-4 text-primary" />
)}
</div>
<span className="font-medium text-sm">
{isNew ? "Alert Created" : "AlertWidget"}
</span>
</div>
{/* Alert details */}
<div className="bg-muted/50 rounded-md p-3 mb-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Token:</span>
<span className="font-medium">{config.tokenSymbol}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Condition:</span>
<span className="font-medium">{config.condition}</span>
</div>
{config.currentPrice && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Current:</span>
<span>{config.currentPrice}</span>
</div>
)}
{config.triggerPrice && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Trigger at:</span>
<span className="font-medium text-primary">{config.triggerPrice}</span>
</div>
)}
{/* Notification channels */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground mb-1">Notify via:</p>
<div className="flex flex-wrap gap-2">
<span className={cn(
"text-xs px-2 py-0.5 rounded",
config.channels.browser ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
)}>
{config.channels.browser ? "✓" : "✗"} Browser
</span>
<span className={cn(
"text-xs px-2 py-0.5 rounded",
config.channels.inApp ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
)}>
{config.channels.inApp ? "✓" : "✗"} In-app
</span>
<span className={cn(
"text-xs px-2 py-0.5 rounded",
config.channels.email ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
)}>
{config.channels.email ? "✓" : "✗"} Email
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onEdit} className="flex-1">
<Edit className="h-3 w-3 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={onAddAnother}>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* View all link */}
{onViewAll && (
<button
onClick={onViewAll}
className="w-full mt-2 text-xs text-primary hover:underline"
>
View all alerts
</button>
)}
</div>
);
}

View file

@ -0,0 +1,184 @@
import { cn } from "~/lib/utils";
import { AlertTriangle, TrendingUp, Info, X, Bell, ChevronRight } from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface ProactiveAlertData {
/** Alert ID */
id: string;
/** Alert type */
type: "price_pump" | "price_dump" | "whale_activity" | "volume_spike" | "safety_warning";
/** Token symbol */
tokenSymbol: string;
/** Alert title */
title: string;
/** Current price */
currentPrice?: string;
/** User's entry price (if applicable) */
entryPrice?: string;
/** User's P&L (if applicable) */
pnl?: string;
/** Warning messages */
warnings?: string[];
/** When the alert was triggered */
timestamp: Date;
}
export interface ProactiveAlertCardProps {
/** Alert data */
alert: ProactiveAlertData;
/** AI's recommendation text */
recommendation?: string;
/** Callback when view details is clicked */
onViewDetails?: () => void;
/** Callback when dismiss is clicked */
onDismiss?: () => void;
/** Callback when set alert is clicked */
onSetAlert?: () => void;
/** Callback when tell me more is clicked */
onTellMore?: () => void;
/** Additional class names */
className?: string;
}
/**
* ProactiveAlertCard - AI-initiated alert card embedded in chat
*
* Displays proactive alerts from the AI about price movements,
* whale activity, or safety concerns. Shows user's position if applicable.
*/
export function ProactiveAlertCard({
alert,
recommendation,
onViewDetails,
onDismiss,
onSetAlert,
onTellMore,
className,
}: ProactiveAlertCardProps) {
const getAlertIcon = () => {
switch (alert.type) {
case "price_pump":
case "price_dump":
return TrendingUp;
case "whale_activity":
case "volume_spike":
return AlertTriangle;
case "safety_warning":
return AlertTriangle;
default:
return Info;
}
};
const getAlertColor = () => {
switch (alert.type) {
case "price_pump":
return "text-green-500 bg-green-500/10";
case "price_dump":
case "safety_warning":
return "text-red-500 bg-red-500/10";
case "whale_activity":
case "volume_spike":
return "text-yellow-500 bg-yellow-500/10";
default:
return "text-primary bg-primary/10";
}
};
const Icon = getAlertIcon();
const colorClass = getAlertColor();
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={cn("w-8 h-8 rounded-full flex items-center justify-center", colorClass)}>
<Icon className="h-4 w-4" />
</div>
<div>
<span className="font-medium text-sm">🚨 ProactiveAlertCard</span>
<p className="text-xs text-muted-foreground">
{alert.timestamp.toLocaleTimeString()}
</p>
</div>
</div>
<button
onClick={onDismiss}
className="p-1 hover:bg-muted rounded text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Alert content */}
<div className="bg-muted/50 rounded-md p-3 mb-3">
<p className="font-medium text-sm mb-2">{alert.title}</p>
{/* Price info */}
{(alert.currentPrice || alert.entryPrice || alert.pnl) && (
<div className="space-y-1 text-xs">
{alert.currentPrice && (
<div className="flex justify-between">
<span className="text-muted-foreground">📊 Current:</span>
<span className="font-medium">{alert.currentPrice}</span>
</div>
)}
{alert.entryPrice && (
<div className="flex justify-between">
<span className="text-muted-foreground">📈 Your entry:</span>
<span>{alert.entryPrice}</span>
</div>
)}
{alert.pnl && (
<div className="flex justify-between">
<span className="text-muted-foreground">💰 Your P&L:</span>
<span className={cn(
"font-medium",
alert.pnl.startsWith("+") ? "text-green-500" : "text-red-500"
)}>{alert.pnl}</span>
</div>
)}
</div>
)}
{/* Warnings */}
{alert.warnings && alert.warnings.length > 0 && (
<div className="mt-2 pt-2 border-t space-y-1">
{alert.warnings.map((warning, index) => (
<div key={index} className="flex items-center gap-2 text-xs text-yellow-600">
<AlertTriangle className="h-3 w-3" />
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
{/* AI Recommendation */}
{recommendation && (
<p className="text-sm text-muted-foreground mb-3 italic">
{recommendation}
</p>
)}
{/* Action buttons */}
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onTellMore} className="flex-1">
<Info className="h-3 w-3 mr-1" />
Tell me more
</Button>
<Button size="sm" variant="outline" onClick={onViewDetails}>
<ChevronRight className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={onSetAlert}>
<Bell className="h-3 w-3" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,196 @@
import { cn } from "~/lib/utils";
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, CheckCircle, Star, Bell } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface TokenAnalysisData {
/** Token symbol */
symbol: string;
/** Token name */
name?: string;
/** Blockchain */
chain: string;
/** Current price */
price: string;
/** 24h price change */
priceChange24h: number;
/** Market cap */
marketCap?: string;
/** 24h volume */
volume24h?: string;
/** Liquidity */
liquidity?: string;
/** Safety score (0-100) */
safetyScore?: number;
/** Holder count */
holderCount?: number;
/** Top 10 holder percentage */
top10HolderPercent?: number;
}
export interface TokenAnalysisWidgetProps {
/** Token analysis data */
data: TokenAnalysisData;
/** Whether token is in watchlist */
isInWatchlist?: boolean;
/** Callback when add to watchlist is clicked */
onAddToWatchlist?: () => void;
/** Callback when set alert is clicked */
onSetAlert?: () => void;
/** Callback when analyze further is clicked */
onAnalyzeFurther?: () => void;
/** Additional class names */
className?: string;
}
/**
* TokenAnalysisWidget - Full token analysis card embedded in chat
*
* Displays comprehensive token analysis including price, safety score,
* and key metrics. Used when AI responds to token research queries.
*/
export function TokenAnalysisWidget({
data,
isInWatchlist = false,
onAddToWatchlist,
onSetAlert,
onAnalyzeFurther,
className,
}: TokenAnalysisWidgetProps) {
const getSafetyColor = (score?: number) => {
if (!score) return "text-muted-foreground";
if (score >= 80) return "text-green-500";
if (score >= 60) return "text-yellow-500";
return "text-red-500";
};
const getSafetyLabel = (score?: number) => {
if (!score) return "Unknown";
if (score >= 80) return "Low Risk";
if (score >= 60) return "Medium Risk";
return "High Risk";
};
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-lg">📊</span>
<span className="font-medium text-sm">TokenAnalysisCard</span>
</div>
<ChainIcon chain={data.chain} size="sm" />
</div>
{/* Token info */}
<div className="flex items-center gap-3 mb-3 pb-3 border-b">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-lg">🪙</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold">{data.symbol}</span>
{data.name && (
<span className="text-xs text-muted-foreground">{data.name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{data.price}</span>
<span className={cn(
"flex items-center gap-0.5 text-sm",
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{data.priceChange24h >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(1)}%
</span>
</div>
</div>
<button
onClick={onAddToWatchlist}
className={cn(
"p-2 rounded-full transition-colors",
isInWatchlist
? "text-yellow-500 bg-yellow-500/10"
: "text-muted-foreground hover:text-yellow-500 hover:bg-yellow-500/10"
)}
>
<Star className={cn("h-5 w-5", isInWatchlist && "fill-current")} />
</button>
</div>
{/* Metrics grid */}
<div className="grid grid-cols-2 gap-2 mb-3 text-sm">
{data.marketCap && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Market Cap</p>
<p className="font-medium">{data.marketCap}</p>
</div>
)}
{data.volume24h && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">24h Volume</p>
<p className="font-medium">{data.volume24h}</p>
</div>
)}
{data.liquidity && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Liquidity</p>
<p className="font-medium">{data.liquidity}</p>
</div>
)}
{data.safetyScore !== undefined && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Safety Score</p>
<p className={cn("font-medium flex items-center gap-1", getSafetyColor(data.safetyScore))}>
<Shield className="h-3 w-3" />
{data.safetyScore}/100 ({getSafetyLabel(data.safetyScore)})
</p>
</div>
)}
</div>
{/* Holder info */}
{(data.holderCount || data.top10HolderPercent) && (
<div className="flex items-center gap-4 mb-3 text-xs text-muted-foreground">
{data.holderCount && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{data.holderCount.toLocaleString()} holders
</span>
)}
{data.top10HolderPercent && (
<span className={cn(
"flex items-center gap-1",
data.top10HolderPercent > 50 ? "text-yellow-500" : ""
)}>
{data.top10HolderPercent > 50 && <AlertTriangle className="h-3 w-3" />}
Top 10: {data.top10HolderPercent}%
</span>
)}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onAddToWatchlist} className="flex-1">
<Star className="h-3 w-3 mr-1" />
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
</Button>
<Button size="sm" variant="outline" onClick={onSetAlert}>
<Bell className="h-3 w-3" />
</Button>
<Button size="sm" variant="default" onClick={onAnalyzeFurther}>
Analyze More
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,151 @@
import { cn } from "~/lib/utils";
import { TrendingUp, TrendingDown, Bell, Trash2, Search, Plus } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface WatchlistItem {
id: string;
symbol: string;
name?: string;
chain: string;
price: string;
priceChange24h: number;
alertCount?: number;
}
export interface WatchlistWidgetProps {
/** List of tokens in watchlist */
tokens: WatchlistItem[];
/** Callback when analyze token is clicked */
onAnalyze?: (token: WatchlistItem) => void;
/** Callback when remove token is clicked */
onRemove?: (tokenId: string) => void;
/** Callback when add token is clicked */
onAddToken?: () => void;
/** Callback when clear all is clicked */
onClearAll?: () => void;
/** Additional class names */
className?: string;
}
/**
* WatchlistWidget - Embedded watchlist widget for chat interface
*
* Displays user's watchlist inline in the chat conversation.
* Supports quick actions like analyze, remove, and add tokens.
*/
export function WatchlistWidget({
tokens,
onAnalyze,
onRemove,
onAddToken,
onClearAll,
className,
}: WatchlistWidgetProps) {
if (tokens.length === 0) {
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
<div className="flex items-center gap-2 mb-3">
<span className="text-lg">📋</span>
<span className="font-medium text-sm">Your Watchlist</span>
</div>
<div className="text-center py-4 text-muted-foreground text-sm">
Your watchlist is empty. Add tokens to track them!
</div>
<Button size="sm" variant="outline" onClick={onAddToken} className="w-full">
<Plus className="h-3 w-3 mr-1" />
Add Token
</Button>
</div>
);
}
return (
<div className={cn(
"rounded-lg border bg-card p-4 my-2",
className
)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-lg">📋</span>
<span className="font-medium text-sm">WatchlistWidget</span>
</div>
<span className="text-xs text-muted-foreground">{tokens.length} tokens</span>
</div>
{/* Token list */}
<div className="space-y-2 mb-3">
{tokens.map((token) => (
<div
key={token.id}
className="flex items-center gap-3 p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
{/* Token info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{token.symbol}</span>
<ChainIcon chain={token.chain} size="sm" />
{token.alertCount && token.alertCount > 0 && (
<span className="flex items-center gap-0.5 text-xs text-primary">
<Bell className="h-3 w-3" />
{token.alertCount}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{token.price}</p>
</div>
{/* Price change */}
<div className={cn(
"flex items-center gap-1 text-xs font-medium",
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{token.priceChange24h >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
</div>
{/* Actions */}
<div className="flex gap-1">
<button
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
onClick={() => onAnalyze?.(token)}
title="Analyze"
>
<Search className="h-3 w-3" />
</button>
<button
className="p-1 hover:bg-destructive/10 rounded text-muted-foreground hover:text-destructive"
onClick={() => onRemove?.(token.id)}
title="Remove"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
{/* Footer actions */}
<div className="flex gap-2 pt-2 border-t">
<Button size="sm" variant="outline" onClick={onAddToken} className="flex-1">
<Plus className="h-3 w-3 mr-1" />
Add Token
</Button>
{tokens.length > 0 && (
<Button size="sm" variant="ghost" onClick={onClearAll} className="text-destructive">
Clear All
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
// Conversational UX Widgets for SurfSense Browser Extension
// These widgets are embedded inline in chat messages for a conversation-first experience
export { ActionConfirmationWidget, type ActionConfirmationProps } from "./ActionConfirmationWidget";
export { ProactiveAlertCard, type ProactiveAlertCardProps, type ProactiveAlertData } from "./ProactiveAlertCard";
export { WatchlistWidget, type WatchlistWidgetProps, type WatchlistItem } from "./WatchlistWidget";
export { AlertWidget, type AlertWidgetProps, type AlertConfigData } from "./AlertWidget";
export { TokenAnalysisWidget, type TokenAnalysisWidgetProps, type TokenAnalysisData } from "./TokenAnalysisWidget";