feat: add new widgets for holder analysis, live token data, price, market overview, and trending tokens

- Implemented HolderAnalysisWidget to display holder distribution and concentration risk.
- Created LiveTokenDataWidget for real-time market data including price changes and transaction activity.
- Added LiveTokenPriceWidget to show current token price and changes over various timeframes.
- Developed MarketOverviewWidget to provide a summary of market statistics and token prices.
- Introduced TrendingTokensWidget to showcase trending tokens with price changes and volume.
- Added TradingSuggestionToolUI for AI-powered trading suggestions with detailed entry, targets, and stop-loss information.
- Enhanced settings components for better user configuration options in the SurfSense Browser Extension.
This commit is contained in:
API Test Bot 2026-02-04 13:11:39 +07:00
parent 2bf40ab5ce
commit 8bc092e40e
23 changed files with 2173 additions and 111 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { usePageContext } from "../context/PageContextProvider";
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
import { QuickCapture } from "./QuickCapture";
@ -22,6 +22,8 @@ import { SafetyScoreDisplay } from "../crypto/SafetyScoreDisplay";
import { WatchlistPanel } from "../crypto/WatchlistPanel";
import { AlertConfigModal } from "../crypto/AlertConfigModal";
import { DetectedTokensList } from "../components/DetectedTokensList";
import { useContextAction, getMessageForAction } from "../hooks/useContextAction";
import { useKeyboardShortcuts, getMessageForKeyboardAction } from "../hooks/useKeyboardShortcuts";
import type { WatchlistItem } from "../widgets";
import type { TokenData } from "../context/PageContextProvider";
@ -78,9 +80,39 @@ export function ChatInterface() {
const [watchlistTokens, setWatchlistTokens] = useState(MOCK_WATCHLIST_TOKENS);
const [isInWatchlist, setIsInWatchlist] = useState(false);
// Context menu action hook
const { pendingAction, clearAction } = useContextAction();
// Keyboard shortcuts hook
const { pendingAction: pendingKeyboardAction, clearAction: clearKeyboardAction } = useKeyboardShortcuts();
// Mock user data - in production, this would come from auth context
const userName = "Crypto Trader";
// Handle context menu actions
useEffect(() => {
if (pendingAction) {
const message = getMessageForAction(pendingAction);
if (message) {
// Auto-send the message
handleSendMessage(message);
}
clearAction();
}
}, [pendingAction, clearAction]);
// Handle keyboard shortcut actions
useEffect(() => {
if (pendingKeyboardAction) {
const message = getMessageForKeyboardAction(pendingKeyboardAction);
if (message) {
// Auto-send the message
handleSendMessage(message);
}
clearKeyboardAction();
}
}, [pendingKeyboardAction, clearKeyboardAction]);
const handleSendMessage = async (content: string, attachments?: AttachedFile[]) => {
console.log("Sending message:", content, attachments);
setIsStreaming(true);

View file

@ -0,0 +1,4 @@
// Hooks for SurfSense Browser Extension
export { useContextAction, getMessageForAction, type ContextAction } from "./useContextAction";
export { useKeyboardShortcuts, getMessageForKeyboardAction, type KeyboardAction } from "./useKeyboardShortcuts";

View file

@ -0,0 +1,104 @@
import { useEffect, useState, useCallback } from "react";
import { Storage } from "@plasmohq/storage";
export interface ContextAction {
action: string;
text: string;
pageUrl?: string;
linkUrl?: string;
timestamp: number;
}
/**
* Hook to handle context menu actions from background script
* Returns pending action and a function to clear it
*/
export function useContextAction() {
const [pendingAction, setPendingAction] = useState<ContextAction | null>(null);
// Check for pending context action on mount and when sidepanel gains focus
const checkPendingAction = useCallback(async () => {
const storage = new Storage({ area: "local" });
const action = await storage.get<ContextAction>("pendingContextAction");
if (action && action.timestamp) {
// Only process actions from last 30 seconds
const isRecent = Date.now() - action.timestamp < 30000;
if (isRecent) {
setPendingAction(action);
// Clear the pending action
await storage.remove("pendingContextAction");
}
}
}, []);
useEffect(() => {
// Check on mount
checkPendingAction();
// Check when window gains focus (sidepanel opened)
const handleFocus = () => {
checkPendingAction();
};
window.addEventListener("focus", handleFocus);
// Also listen for visibility change
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
checkPendingAction();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [checkPendingAction]);
const clearAction = useCallback(() => {
setPendingAction(null);
}, []);
return { pendingAction, clearAction, checkPendingAction };
}
/**
* Generate chat message based on context action
*/
export function getMessageForAction(action: ContextAction): string | null {
const text = action.text;
switch (action.action) {
case "analyze-token":
return `Analyze token: ${text}`;
case "check-safety":
return `Is ${text} safe? Check for rug pull risks.`;
case "add-watchlist":
return `Add ${text} to my watchlist`;
case "copy-address":
// This is handled differently - just copy to clipboard
if (text) {
navigator.clipboard.writeText(text);
}
return null;
case "view-explorer":
// Detect chain and open explorer
if (text.startsWith("0x") && text.length === 42) {
// Ethereum address
window.open(`https://etherscan.io/address/${text}`, "_blank");
} else if (text.length >= 32 && text.length <= 44) {
// Solana address
window.open(`https://solscan.io/account/${text}`, "_blank");
}
return null;
case "capture-page":
return "Capture this page to my knowledge base";
case "ask-ai-page":
return "What is this page about? Summarize the key information.";
default:
return null;
}
}

View file

@ -0,0 +1,92 @@
import { useEffect, useState, useCallback } from "react";
import { Storage } from "@plasmohq/storage";
export interface KeyboardAction {
action: string;
timestamp: number;
}
/**
* Hook to handle keyboard shortcut actions from background script
* Returns pending action and a function to clear it
*
* Keyboard shortcuts defined in manifest:
* - open-sidepanel: Ctrl+Shift+S (just opens panel, no message)
* - analyze-token: Ctrl+Shift+A
* - add-watchlist: Ctrl+Shift+W
* - capture-page: Ctrl+Shift+C
* - show-portfolio: Ctrl+Shift+P
*/
export function useKeyboardShortcuts() {
const [pendingAction, setPendingAction] = useState<KeyboardAction | null>(null);
// Check for pending keyboard action on mount and when sidepanel gains focus
const checkPendingAction = useCallback(async () => {
const storage = new Storage({ area: "local" });
const action = await storage.get<KeyboardAction>("pendingKeyboardAction");
if (action && action.timestamp) {
// Only process actions from last 30 seconds
const isRecent = Date.now() - action.timestamp < 30000;
if (isRecent) {
setPendingAction(action);
// Clear the pending action
await storage.remove("pendingKeyboardAction");
}
}
}, []);
useEffect(() => {
// Check on mount
checkPendingAction();
// Check when window gains focus (sidepanel opened)
const handleFocus = () => {
checkPendingAction();
};
window.addEventListener("focus", handleFocus);
// Also listen for visibility change
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
checkPendingAction();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [checkPendingAction]);
const clearAction = useCallback(() => {
setPendingAction(null);
}, []);
return { pendingAction, clearAction, checkPendingAction };
}
/**
* Generate chat message based on keyboard shortcut action
* Returns null for actions that don't need a chat message (like open-sidepanel)
*/
export function getMessageForKeyboardAction(action: KeyboardAction): string | null {
switch (action.action) {
case "open-sidepanel":
// Just opens the panel, no message needed
return null;
case "analyze-token":
return "Analyze the current token on this page";
case "add-watchlist":
return "Add the current token to my watchlist";
case "capture-page":
return "Capture this page to my knowledge base";
case "show-portfolio":
return "Show my portfolio";
default:
return null;
}
}

View file

@ -0,0 +1,225 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import { Bell, BellOff, Volume2, VolumeX, Clock, Filter, ChevronRight } from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface NotificationSettings {
enabled: boolean;
sound: boolean;
quietHoursEnabled: boolean;
quietHoursStart: string;
quietHoursEnd: string;
groupNotifications: boolean;
priorities: {
high: boolean;
medium: boolean;
low: boolean;
};
categories: {
priceAlerts: boolean;
whaleActivity: boolean;
rugPullWarnings: boolean;
portfolioUpdates: boolean;
newsAlerts: boolean;
};
}
export interface NotificationSettingsPanelProps {
settings: NotificationSettings;
onSettingsChange: (settings: NotificationSettings) => void;
className?: string;
}
const DEFAULT_SETTINGS: NotificationSettings = {
enabled: true,
sound: true,
quietHoursEnabled: false,
quietHoursStart: "22:00",
quietHoursEnd: "08:00",
groupNotifications: true,
priorities: {
high: true,
medium: true,
low: false,
},
categories: {
priceAlerts: true,
whaleActivity: true,
rugPullWarnings: true,
portfolioUpdates: true,
newsAlerts: false,
},
};
/**
* NotificationSettingsPanel - Configure notification preferences
* Part of Epic 4.4 - Smart Notifications
*/
export function NotificationSettingsPanel({
settings = DEFAULT_SETTINGS,
onSettingsChange,
className,
}: NotificationSettingsPanelProps) {
const updateSettings = (partial: Partial<NotificationSettings>) => {
onSettingsChange({ ...settings, ...partial });
};
const updatePriority = (key: keyof NotificationSettings["priorities"], value: boolean) => {
onSettingsChange({
...settings,
priorities: { ...settings.priorities, [key]: value },
});
};
const updateCategory = (key: keyof NotificationSettings["categories"], value: boolean) => {
onSettingsChange({
...settings,
categories: { ...settings.categories, [key]: value },
});
};
return (
<div className={cn("rounded-lg border bg-card p-4 space-y-4", className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<span className="font-medium">Notification Settings</span>
</div>
<Button
variant={settings.enabled ? "default" : "outline"}
size="sm"
onClick={() => updateSettings({ enabled: !settings.enabled })}
>
{settings.enabled ? (
<>
<Bell className="h-4 w-4 mr-1" /> On
</>
) : (
<>
<BellOff className="h-4 w-4 mr-1" /> Off
</>
)}
</Button>
</div>
{settings.enabled && (
<>
{/* Sound Toggle */}
<div className="flex items-center justify-between py-2 border-b">
<div className="flex items-center gap-2">
{settings.sound ? (
<Volume2 className="h-4 w-4 text-muted-foreground" />
) : (
<VolumeX className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm">Sound</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSettings({ sound: !settings.sound })}
>
{settings.sound ? "On" : "Off"}
</Button>
</div>
{/* Quiet Hours */}
<div className="space-y-2 py-2 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Quiet Hours</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSettings({ quietHoursEnabled: !settings.quietHoursEnabled })}
>
{settings.quietHoursEnabled ? "On" : "Off"}
</Button>
</div>
{settings.quietHoursEnabled && (
<div className="flex items-center gap-2 ml-6 text-xs text-muted-foreground">
<input
type="time"
value={settings.quietHoursStart}
onChange={(e) => updateSettings({ quietHoursStart: e.target.value })}
className="bg-muted rounded px-2 py-1"
/>
<span>to</span>
<input
type="time"
value={settings.quietHoursEnd}
onChange={(e) => updateSettings({ quietHoursEnd: e.target.value })}
className="bg-muted rounded px-2 py-1"
/>
</div>
)}
</div>
{/* Priority Levels */}
<div className="space-y-2 py-2 border-b">
<div className="flex items-center gap-2 mb-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Priority Levels</span>
</div>
<div className="space-y-1 ml-6">
{(["high", "medium", "low"] as const).map((priority) => (
<label key={priority} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.priorities[priority]}
onChange={(e) => updatePriority(priority, e.target.checked)}
className="rounded"
/>
<span className={cn(
"text-xs capitalize",
priority === "high" && "text-red-500",
priority === "medium" && "text-yellow-500",
priority === "low" && "text-muted-foreground"
)}>
{priority}
</span>
</label>
))}
</div>
</div>
{/* Categories */}
<div className="space-y-2 py-2">
<span className="text-sm font-medium">Categories</span>
<div className="space-y-1">
{Object.entries(settings.categories).map(([key, value]) => (
<label key={key} className="flex items-center justify-between cursor-pointer py-1">
<span className="text-xs text-muted-foreground">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</span>
<input
type="checkbox"
checked={value}
onChange={(e) => updateCategory(key as keyof NotificationSettings["categories"], e.target.checked)}
className="rounded"
/>
</label>
))}
</div>
</div>
{/* Group Notifications */}
<div className="flex items-center justify-between pt-2 border-t">
<span className="text-sm">Group similar notifications</span>
<Button
variant="ghost"
size="sm"
onClick={() => updateSettings({ groupNotifications: !settings.groupNotifications })}
>
{settings.groupNotifications ? "On" : "Off"}
</Button>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,170 @@
import { cn } from "~/lib/utils";
import { Bell, AlertTriangle, TrendingUp, TrendingDown, Wallet, Fish, X, Check } from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface Notification {
id: string;
type: "price_alert" | "whale_activity" | "rug_warning" | "portfolio" | "news";
priority: "high" | "medium" | "low";
title: string;
message: string;
tokenSymbol?: string;
timestamp: Date;
read: boolean;
actionUrl?: string;
}
export interface NotificationsListProps {
notifications: Notification[];
onMarkRead: (id: string) => void;
onMarkAllRead: () => void;
onDismiss: (id: string) => void;
onNotificationClick?: (notification: Notification) => void;
className?: string;
}
const getNotificationIcon = (type: Notification["type"]) => {
switch (type) {
case "price_alert":
return <TrendingUp className="h-4 w-4" />;
case "whale_activity":
return <Fish className="h-4 w-4" />;
case "rug_warning":
return <AlertTriangle className="h-4 w-4" />;
case "portfolio":
return <Wallet className="h-4 w-4" />;
case "news":
return <Bell className="h-4 w-4" />;
default:
return <Bell className="h-4 w-4" />;
}
};
const getPriorityColor = (priority: Notification["priority"]) => {
switch (priority) {
case "high":
return "border-l-red-500 bg-red-500/5";
case "medium":
return "border-l-yellow-500 bg-yellow-500/5";
case "low":
return "border-l-muted-foreground bg-muted/30";
default:
return "border-l-muted-foreground";
}
};
const formatTime = (date: Date): string => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
/**
* NotificationsList - Display and manage notifications
* Part of Epic 4.4 - Smart Notifications
*/
export function NotificationsList({
notifications,
onMarkRead,
onMarkAllRead,
onDismiss,
onNotificationClick,
className,
}: NotificationsListProps) {
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<div className={cn("rounded-lg border bg-card", className)}>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">Notifications</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
{unreadCount}
</span>
)}
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="text-xs" onClick={onMarkAllRead}>
<Check className="h-3 w-3 mr-1" />
Mark all read
</Button>
)}
</div>
{/* Notifications List */}
<div className="max-h-[400px] overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No notifications yet</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"flex items-start gap-3 p-3 border-b border-l-2 cursor-pointer hover:bg-muted/50 transition-colors",
getPriorityColor(notification.priority),
!notification.read && "bg-primary/5"
)}
onClick={() => {
if (!notification.read) onMarkRead(notification.id);
onNotificationClick?.(notification);
}}
>
<div className={cn(
"p-1.5 rounded-full",
notification.type === "rug_warning" ? "bg-red-500/20 text-red-500" :
notification.type === "whale_activity" ? "bg-blue-500/20 text-blue-500" :
notification.type === "price_alert" ? "bg-green-500/20 text-green-500" :
"bg-muted text-muted-foreground"
)}>
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn("text-sm font-medium", !notification.read && "text-foreground")}>
{notification.title}
</span>
{notification.tokenSymbol && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
{notification.tokenSymbol}
</span>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<span className="text-[10px] text-muted-foreground mt-1 block">
{formatTime(notification.timestamp)}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onDismiss(notification.id);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
// Settings components for SurfSense Browser Extension
export { NotificationSettingsPanel, type NotificationSettings, type NotificationSettingsPanelProps } from "./NotificationSettingsPanel";
export { NotificationsList, type Notification, type NotificationsListProps } from "./NotificationsList";
export { ProductivitySettings } from "./ProductivitySettings";

View file

@ -0,0 +1,145 @@
import { cn } from "~/lib/utils";
import { Users, AlertTriangle, Crown } from "lucide-react";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface Holder {
rank: number;
address: string;
label?: string;
balance: number;
percentage: number;
isContract?: boolean;
}
export interface HolderAnalysisData {
tokenSymbol: string;
chain: string;
totalHolders: number;
top10Percentage: number;
top50Percentage?: number;
holders: Holder[];
concentrationRisk?: "low" | "medium" | "high" | "critical";
}
export interface HolderAnalysisWidgetProps {
/** Holder analysis data */
data: HolderAnalysisData;
/** Callback when holder is clicked */
onHolderClick?: (holder: Holder) => void;
/** Additional class names */
className?: string;
}
const shortenAddress = (address: string): string => {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
const formatBalance = (balance: number): string => {
if (balance >= 1e9) return `${(balance / 1e9).toFixed(2)}B`;
if (balance >= 1e6) return `${(balance / 1e6).toFixed(2)}M`;
if (balance >= 1e3) return `${(balance / 1e3).toFixed(2)}K`;
return balance.toFixed(2);
};
const getRiskColor = (risk: string) => {
switch (risk) {
case "low": return "text-green-500 bg-green-500/10";
case "medium": return "text-yellow-500 bg-yellow-500/10";
case "high": return "text-orange-500 bg-orange-500/10";
case "critical": return "text-red-500 bg-red-500/10";
default: return "text-muted-foreground bg-muted";
}
};
/**
* HolderAnalysisWidget - Displays holder distribution inline in chat
* Used when AI responds to "who holds BULLA?" or "analyze holders"
*/
export function HolderAnalysisWidget({
data,
onHolderClick,
className,
}: HolderAnalysisWidgetProps) {
const risk = data.concentrationRisk || "medium";
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">
<Users className="h-5 w-5 text-purple-500" />
<span className="font-medium text-sm">Holder Analysis - {data.tokenSymbol}</span>
</div>
<ChainIcon chain={data.chain} size="sm" />
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Total Holders</p>
<p className="font-medium text-sm">{data.totalHolders.toLocaleString()}</p>
</div>
<div className={cn("rounded p-2", data.top10Percentage > 50 ? "bg-red-500/10" : "bg-muted/50")}>
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
<p className={cn("font-medium text-sm", data.top10Percentage > 50 && "text-red-500")}>
{data.top10Percentage.toFixed(1)}%
</p>
</div>
{data.top50Percentage && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
<p className="font-medium text-sm">{data.top50Percentage.toFixed(1)}%</p>
</div>
)}
<div className={cn("rounded p-2", getRiskColor(risk))}>
<p className="text-xs text-muted-foreground">Concentration Risk</p>
<p className="font-medium text-sm capitalize">{risk}</p>
</div>
</div>
{/* Risk Warning */}
{(risk === "high" || risk === "critical") && (
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-xs bg-yellow-500/10 rounded-lg p-2 mb-3">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
<span>High holder concentration. Top wallets could impact price.</span>
</div>
)}
{/* Top Holders List */}
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground mb-2">Top Holders</p>
<div className="divide-y max-h-[200px] overflow-y-auto">
{data.holders.slice(0, 10).map((holder) => (
<div
key={holder.address}
className="flex items-center justify-between py-2 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
onClick={() => onHolderClick?.(holder)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground w-5">#{holder.rank}</span>
{holder.rank <= 3 && (
<Crown className={cn(
"h-3.5 w-3.5",
holder.rank === 1 ? "text-yellow-500" :
holder.rank === 2 ? "text-gray-400" : "text-amber-600"
)} />
)}
<div>
<p className="font-medium text-xs">{holder.label || shortenAddress(holder.address)}</p>
{holder.isContract && (
<span className="text-[10px] bg-muted px-1 rounded">Contract</span>
)}
</div>
</div>
<div className="text-right">
<p className="font-medium text-xs">{holder.percentage.toFixed(2)}%</p>
<p className="text-[10px] text-muted-foreground">{formatBalance(holder.balance)}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,218 @@
import { cn } from "~/lib/utils";
import { Activity, TrendingUp, TrendingDown, RefreshCw, ExternalLink, Droplets, BarChart3 } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface LiveTokenDataInfo {
chain: string;
tokenAddress: string;
tokenSymbol?: string;
tokenName?: string;
priceUsd?: string;
priceNative?: string;
priceChange5m?: number;
priceChange1h?: number;
priceChange6h?: number;
priceChange24h?: number;
volume24h?: number;
volume6h?: number;
volume1h?: number;
liquidityUsd?: number;
marketCap?: number;
fdv?: number;
txns24hBuys?: number;
txns24hSells?: number;
dex?: string;
pairUrl?: string;
totalPairs?: number;
error?: string;
}
export interface LiveTokenDataWidgetProps {
/** Live token data */
data: LiveTokenDataInfo;
/** Whether data is loading */
isLoading?: boolean;
/** Callback when view on DexScreener is clicked */
onViewDexScreener?: () => void;
/** Additional class names */
className?: string;
}
const formatPrice = (price: string | undefined): string => {
if (!price || price === "N/A") return "N/A";
const num = parseFloat(price);
if (isNaN(num)) return price;
if (num < 0.00001) return `$${num.toExponential(2)}`;
if (num < 1) return `$${num.toFixed(6)}`;
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null || num === 0) return "N/A";
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const formatNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return "0";
return num.toLocaleString();
};
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
if (value === undefined || value === null) return null;
const isPositive = value >= 0;
return (
<div className="text-center">
<p className="text-[10px] text-muted-foreground">{label}</p>
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
{isPositive ? "+" : ""}{value.toFixed(2)}%
</p>
</div>
);
};
/**
* LiveTokenDataWidget - Displays comprehensive real-time market data
* Used when AI fetches detailed live market information
*/
export function LiveTokenDataWidget({
data,
isLoading = false,
onViewDexScreener,
className,
}: LiveTokenDataWidgetProps) {
const handleOpenDexScreener = () => {
if (onViewDexScreener) {
onViewDexScreener();
} else if (data.pairUrl) {
window.open(data.pairUrl, "_blank");
} else if (data.tokenAddress) {
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
}
};
const totalTxns24h = (data.txns24hBuys || 0) + (data.txns24hSells || 0);
const buyRatio = totalTxns24h > 0 ? ((data.txns24hBuys || 0) / totalTxns24h) * 100 : 50;
return (
<div className={cn("rounded-lg border border-purple-500/20 bg-card p-4 my-2", className)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-purple-500" />
<span className="font-medium text-sm">Live Market Data</span>
{isLoading ? (
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
) : (
<span className="text-xs text-purple-500 flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
Real-time
</span>
)}
</div>
</div>
{data.error ? (
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
{data.error}
</div>
) : (
<>
{/* Token Header */}
<div className="flex items-center gap-3 mb-3">
<ChainIcon chain={data.chain} size="sm" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
{data.tokenName && (
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
{data.priceChange24h !== undefined && (
<span className={cn(
"flex items-center gap-0.5 text-xs font-medium",
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(2)}%
</span>
)}
</div>
</div>
</div>
{/* Price Changes */}
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
<PriceChange value={data.priceChange5m} label="5m" />
<PriceChange value={data.priceChange1h} label="1h" />
<PriceChange value={data.priceChange6h} label="6h" />
<PriceChange value={data.priceChange24h} label="24h" />
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-muted/50 rounded p-2">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<BarChart3 className="h-3 w-3" /> 24h Volume
</p>
<p className="font-medium text-sm">{formatLargeNumber(data.volume24h)}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Droplets className="h-3 w-3" /> Liquidity
</p>
<p className="font-medium text-sm">{formatLargeNumber(data.liquidityUsd)}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-[10px] text-muted-foreground">Market Cap</p>
<p className="font-medium text-sm">{formatLargeNumber(data.marketCap)}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-[10px] text-muted-foreground">FDV</p>
<p className="font-medium text-sm">{formatLargeNumber(data.fdv)}</p>
</div>
</div>
{/* Transaction Activity */}
<div className="space-y-1 mb-3">
<p className="text-xs font-medium flex items-center gap-1">
<Activity className="h-3 w-3" /> 24h Transactions
</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${buyRatio}%` }}
/>
</div>
</div>
<div className="flex justify-between text-[10px]">
<span className="text-green-500">{formatNumber(data.txns24hBuys)} buys</span>
<span className="text-muted-foreground">{formatNumber(totalTxns24h)} total</span>
<span className="text-red-500">{formatNumber(data.txns24hSells)} sells</span>
</div>
</div>
{/* DEX Info & Actions */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="text-[10px] text-muted-foreground">
<span>DEX: {data.dex || "Unknown"}</span>
{data.totalPairs && data.totalPairs > 1 && (
<span className="ml-2"> {data.totalPairs} pairs</span>
)}
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleOpenDexScreener}>
<ExternalLink className="h-3 w-3 mr-1" />
DexScreener
</Button>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,155 @@
import { cn } from "~/lib/utils";
import { Zap, TrendingUp, TrendingDown, RefreshCw, ExternalLink } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface LiveTokenPriceData {
chain: string;
tokenAddress: string;
tokenSymbol?: string;
tokenName?: string;
priceUsd?: string;
priceNative?: string;
priceChange5m?: number;
priceChange1h?: number;
priceChange6h?: number;
priceChange24h?: number;
volume24h?: number;
liquidityUsd?: number;
marketCap?: number;
fdv?: number;
dex?: string;
pairUrl?: string;
error?: string;
}
export interface LiveTokenPriceWidgetProps {
/** Live token price data */
data: LiveTokenPriceData;
/** Whether data is loading */
isLoading?: boolean;
/** Callback when view on DexScreener is clicked */
onViewDexScreener?: () => void;
/** Additional class names */
className?: string;
}
const formatPrice = (price: string | undefined): string => {
if (!price || price === "N/A") return "N/A";
const num = parseFloat(price);
if (isNaN(num)) return price;
if (num < 0.00001) return `$${num.toExponential(2)}`;
if (num < 1) return `$${num.toFixed(6)}`;
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
if (value === undefined || value === null) return null;
const isPositive = value >= 0;
return (
<div className="text-center">
<p className="text-[10px] text-muted-foreground">{label}</p>
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
{isPositive ? "+" : ""}{value.toFixed(2)}%
</p>
</div>
);
};
/**
* LiveTokenPriceWidget - Displays real-time token price inline in chat
* Used when AI fetches current/live price data
*/
export function LiveTokenPriceWidget({
data,
isLoading = false,
onViewDexScreener,
className,
}: LiveTokenPriceWidgetProps) {
const handleOpenDexScreener = () => {
if (onViewDexScreener) {
onViewDexScreener();
} else if (data.pairUrl) {
window.open(data.pairUrl, "_blank");
} else if (data.tokenAddress) {
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
}
};
return (
<div className={cn("rounded-lg border border-blue-500/20 bg-card p-4 my-2", className)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-blue-500" />
<span className="font-medium text-sm">Live Price</span>
{isLoading ? (
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
) : (
<span className="text-xs text-blue-500 flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
Real-time
</span>
)}
</div>
</div>
{data.error ? (
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
{data.error}
</div>
) : (
<>
{/* Token Header */}
<div className="flex items-center gap-3 mb-3">
<ChainIcon chain={data.chain} size="sm" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
{data.tokenName && (
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
{data.priceChange24h !== undefined && (
<span className={cn(
"flex items-center gap-0.5 text-xs font-medium",
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(2)}%
</span>
)}
</div>
</div>
</div>
{/* Price Changes */}
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
<PriceChange value={data.priceChange5m} label="5m" />
<PriceChange value={data.priceChange1h} label="1h" />
<PriceChange value={data.priceChange6h} label="6h" />
<PriceChange value={data.priceChange24h} label="24h" />
</div>
{/* Action */}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={handleOpenDexScreener}
>
<ExternalLink className="h-3 w-3 mr-1" />
View on DexScreener
</Button>
</>
)}
</div>
);
}

View file

@ -0,0 +1,136 @@
import { cn } from "~/lib/utils";
import { Globe, TrendingUp, TrendingDown } from "lucide-react";
export interface MarketToken {
symbol: string;
name: string;
price: number;
priceChange24h: number;
marketCap?: number;
volume24h?: number;
}
export interface MarketOverviewData {
tokens: MarketToken[];
totalMarketCap?: number;
totalVolume24h?: number;
btcDominance?: number;
fearGreedIndex?: number;
}
export interface MarketOverviewWidgetProps {
/** Market overview data */
data: MarketOverviewData;
/** Callback when token is clicked */
onTokenClick?: (token: MarketToken) => void;
/** Additional class names */
className?: string;
}
const formatPrice = (price: number): string => {
if (price < 1) return `$${price.toFixed(4)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number): string => {
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
return `$${num.toFixed(2)}`;
};
const getFearGreedLabel = (index: number): string => {
if (index > 75) return "Extreme Greed";
if (index > 50) return "Greed";
if (index > 25) return "Fear";
return "Extreme Fear";
};
/**
* MarketOverviewWidget - Displays market overview inline in chat
* Used when AI responds to "show market overview" or "how's the market?"
*/
export function MarketOverviewWidget({
data,
onTokenClick,
className,
}: MarketOverviewWidgetProps) {
return (
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Globe className="h-5 w-5 text-blue-500" />
<span className="font-medium text-sm">Market Overview</span>
</div>
{/* Global Stats */}
{(data.totalMarketCap || data.btcDominance || data.fearGreedIndex) && (
<div className="grid grid-cols-2 gap-2 mb-3">
{data.totalMarketCap && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">Total Market Cap</p>
<p className="font-medium text-sm">{formatLargeNumber(data.totalMarketCap)}</p>
</div>
)}
{data.totalVolume24h && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">24h Volume</p>
<p className="font-medium text-sm">{formatLargeNumber(data.totalVolume24h)}</p>
</div>
)}
{data.btcDominance && (
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground">BTC Dominance</p>
<p className="font-medium text-sm">{data.btcDominance.toFixed(1)}%</p>
</div>
)}
{data.fearGreedIndex && (
<div className={cn(
"rounded p-2",
data.fearGreedIndex > 50 ? "bg-green-500/10" : "bg-red-500/10"
)}>
<p className="text-xs text-muted-foreground">Fear & Greed</p>
<p className={cn(
"font-medium text-sm",
data.fearGreedIndex > 50 ? "text-green-500" : "text-red-500"
)}>
{data.fearGreedIndex} - {getFearGreedLabel(data.fearGreedIndex)}
</p>
</div>
)}
</div>
)}
{/* Token Prices */}
<div className="space-y-2">
{data.tokens.map((token) => (
<div
key={token.symbol}
className="bg-muted/50 rounded p-3 flex items-center justify-between hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => onTokenClick?.(token)}
>
<div>
<p className="font-bold">{token.symbol}</p>
<p className="text-xs text-muted-foreground">{token.name}</p>
</div>
<div className="text-right">
<p className="font-medium">{formatPrice(token.price)}</p>
<p className={cn(
"text-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>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
import { cn } from "~/lib/utils";
import { Flame, TrendingUp, TrendingDown, Star } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface TrendingToken {
symbol: string;
name: string;
chain: string;
contractAddress?: string;
price: number;
priceChange24h: number;
priceChange1h?: number;
volume24h?: number;
liquidity?: number;
rank?: number;
}
export interface TrendingTokensWidgetProps {
/** List of trending tokens */
tokens: TrendingToken[];
/** Filter by chain (optional) */
chain?: string;
/** Timeframe for trending data */
timeframe?: string;
/** Callback when token is clicked */
onTokenClick?: (token: TrendingToken) => void;
/** Callback when add to watchlist is clicked */
onAddToWatchlist?: (token: TrendingToken) => void;
/** Additional class names */
className?: string;
}
const formatPrice = (price: number): string => {
if (price < 0.00001) return `$${price.toExponential(2)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number): string => {
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
/**
* TrendingTokensWidget - Displays trending/hot tokens inline in chat
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
*/
export function TrendingTokensWidget({
tokens,
chain = "All Chains",
timeframe = "24h",
onTokenClick,
onAddToWatchlist,
className,
}: TrendingTokensWidgetProps) {
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">
<Flame className="h-5 w-5 text-orange-500" />
<span className="font-medium text-sm">Trending on {chain}</span>
<span className="text-xs bg-muted px-2 py-0.5 rounded">{timeframe}</span>
</div>
</div>
{/* Token List */}
{tokens.length === 0 ? (
<p className="text-muted-foreground text-center py-4 text-sm">No trending tokens found</p>
) : (
<div className="divide-y">
{tokens.map((token, index) => (
<div
key={token.symbol + index}
className="flex items-center justify-between py-2.5 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
onClick={() => onTokenClick?.(token)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-muted-foreground w-5">
#{token.rank || index + 1}
</span>
<ChainIcon chain={token.chain} size="xs" />
<div>
<div className="flex items-center gap-1">
<span className="font-medium text-sm">{token.symbol}</span>
</div>
<span className="text-xs text-muted-foreground">{token.name}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="font-medium text-sm">{formatPrice(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(1)}%
</p>
</div>
{token.volume24h && (
<div className="text-right hidden sm:block">
<p className="text-xs text-muted-foreground">Vol</p>
<p className="text-xs">{formatLargeNumber(token.volume24h)}</p>
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onAddToWatchlist?.(token);
}}
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -9,12 +9,19 @@ export { TokenAnalysisWidget, type TokenAnalysisWidgetProps, type TokenAnalysisD
// Epic 2: Smart Monitoring & Alerts
export { WhaleActivityWidget, type WhaleActivityWidgetProps } from "./WhaleActivityWidget";
export { TrendingTokensWidget, type TrendingTokensWidgetProps, type TrendingToken } from "./TrendingTokensWidget";
// Epic 3: Trading Intelligence
export { TradingSuggestionWidget, type TradingSuggestionWidgetProps } from "./TradingSuggestionWidget";
export { PortfolioWidget, type PortfolioWidgetProps } from "./PortfolioWidget";
export { HolderAnalysisWidget, type HolderAnalysisWidgetProps, type HolderAnalysisData, type Holder } from "./HolderAnalysisWidget";
// Epic 4: Content Creation & Productivity
export { ChartCaptureWidget, type ChartCaptureWidgetProps } from "./ChartCaptureWidget";
export { ThreadGeneratorWidget, type ThreadGeneratorWidgetProps } from "./ThreadGeneratorWidget";
// Market Data Widgets
export { MarketOverviewWidget, type MarketOverviewWidgetProps, type MarketOverviewData, type MarketToken } from "./MarketOverviewWidget";
export { LiveTokenPriceWidget, type LiveTokenPriceWidgetProps, type LiveTokenPriceData } from "./LiveTokenPriceWidget";
export { LiveTokenDataWidget, type LiveTokenDataWidgetProps, type LiveTokenDataInfo } from "./LiveTokenDataWidget";