chore: merge upstream with local feature additions

- Merged dexscreener connector, composio connectors, crypto realtime tools from upstream
- Kept local additions: dropbox/onedrive connectors, memory routes, model_list routes, RefreshToken model
- Resolved frontend conflicts: kept tool UIs from both sides
- Accepted upstream lock files (uv.lock, pnpm-lock.yaml)
This commit is contained in:
Vonic 2026-04-13 23:31:52 +07:00
commit 6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions

View file

@ -2,6 +2,175 @@ import { Storage } from "@plasmohq/storage";
import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
// Configure side panel to open when extension icon is clicked
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error) => console.error("Failed to set side panel behavior:", error));
// ============================================
// Context Menu Setup (Epic 4.3)
// ============================================
// Create context menus on extension install
chrome.runtime.onInstalled.addListener(() => {
// Parent menu for SurfSense
chrome.contextMenus.create({
id: "surfsense-parent",
title: "🧠 SurfSense",
contexts: ["selection", "page", "link"],
});
// Analyze Token - for selected text (token address or symbol)
chrome.contextMenus.create({
id: "analyze-token",
parentId: "surfsense-parent",
title: "🔍 Analyze Token",
contexts: ["selection"],
});
// Check Safety - for selected text
chrome.contextMenus.create({
id: "check-safety",
parentId: "surfsense-parent",
title: "🛡️ Check Safety",
contexts: ["selection"],
});
// Add to Watchlist - for selected text
chrome.contextMenus.create({
id: "add-watchlist",
parentId: "surfsense-parent",
title: "⭐ Add to Watchlist",
contexts: ["selection"],
});
// Separator
chrome.contextMenus.create({
id: "separator-1",
parentId: "surfsense-parent",
type: "separator",
contexts: ["selection", "page", "link"],
});
// Copy Address - for selected text
chrome.contextMenus.create({
id: "copy-address",
parentId: "surfsense-parent",
title: "📋 Copy Address",
contexts: ["selection"],
});
// View on Explorer - for selected text
chrome.contextMenus.create({
id: "view-explorer",
parentId: "surfsense-parent",
title: "🔗 View on Explorer",
contexts: ["selection"],
});
// Separator
chrome.contextMenus.create({
id: "separator-2",
parentId: "surfsense-parent",
type: "separator",
contexts: ["selection", "page", "link"],
});
// Capture Page - for page context
chrome.contextMenus.create({
id: "capture-page",
parentId: "surfsense-parent",
title: "📸 Capture This Page",
contexts: ["page"],
});
// Ask AI about this page
chrome.contextMenus.create({
id: "ask-ai-page",
parentId: "surfsense-parent",
title: "💬 Ask AI About This Page",
contexts: ["page"],
});
});
// Handle context menu clicks
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const selectedText = info.selectionText?.trim() || "";
const storage = new Storage({ area: "local" });
// Store the action for sidepanel to pick up
const contextAction = {
action: info.menuItemId,
text: selectedText,
pageUrl: info.pageUrl,
linkUrl: info.linkUrl,
timestamp: Date.now(),
};
await storage.set("pendingContextAction", contextAction);
// Open sidepanel to handle the action
if (tab?.id) {
try {
await chrome.sidePanel.open({ tabId: tab.id });
} catch (error) {
console.error("Failed to open side panel:", error);
}
}
});
// ============================================
// Message Listeners
// ============================================
// Listen for messages from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "OPEN_SIDEPANEL") {
// Open sidepanel for the current tab
if (sender.tab?.id) {
chrome.sidePanel.open({ tabId: sender.tab.id })
.catch((error) => console.error("Failed to open side panel:", error));
}
}
// Handle context action from sidepanel
if (message.type === "GET_CONTEXT_ACTION") {
const storage = new Storage({ area: "local" });
storage.get("pendingContextAction").then((action) => {
sendResponse(action);
// Clear the pending action
storage.remove("pendingContextAction");
});
return true; // Keep channel open for async response
}
});
// ============================================
// Keyboard Shortcuts (Epic 4.5)
// ============================================
chrome.commands.onCommand.addListener(async (command) => {
const storage = new Storage({ area: "local" });
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
// Store the keyboard command for sidepanel to pick up
const keyboardAction = {
action: command,
timestamp: Date.now(),
};
await storage.set("pendingKeyboardAction", keyboardAction);
// Open sidepanel for all commands
try {
await chrome.sidePanel.open({ tabId: tab.id });
} catch (error) {
console.error("Failed to open side panel:", error);
}
});
chrome.tabs.onCreated.addListener(async (tab: any) => {
try {
await initWebHistory(tab.id);

View file

@ -5,3 +5,285 @@ export const config: PlasmoCSConfig = {
all_frames: true,
world: "MAIN",
};
/**
* Content script for page context detection
* Extracts relevant data from crypto pages and sends to side panel
*/
type PageType = "dexscreener" | "coingecko" | "twitter" | "generic";
interface TokenData {
chain: string;
pairAddress: string;
tokenSymbol?: string;
price?: string;
volume24h?: string;
liquidity?: string;
}
interface PageContext {
url: string;
title: string;
pageType: PageType;
tokenData?: TokenData;
}
/**
* Detect page type from URL
*/
function detectPageType(url: string): PageType {
if (url.includes("dexscreener.com")) return "dexscreener";
if (url.includes("coingecko.com")) return "coingecko";
if (url.includes("twitter.com") || url.includes("x.com")) return "twitter";
return "generic";
}
/**
* Extract DexScreener token data from DOM
*/
function extractDexScreenerData(): TokenData | undefined {
const url = window.location.href;
const match = url.match(/dexscreener\.com\/([^\/]+)\/([^\/\?]+)/);
if (!match) return undefined;
const [, chain, pairAddress] = match;
// Try to extract data from DOM
// Note: DexScreener uses dynamic rendering, so selectors may need adjustment
const tokenSymbol =
document.querySelector('[data-test="token-symbol"]')?.textContent ||
document.querySelector(".token-symbol")?.textContent ||
undefined;
const price =
document.querySelector('[data-test="token-price"]')?.textContent ||
document.querySelector(".token-price")?.textContent ||
undefined;
const volume24h =
document.querySelector('[data-test="volume-24h"]')?.textContent ||
document.querySelector(".volume-24h")?.textContent ||
undefined;
const liquidity =
document.querySelector('[data-test="liquidity"]')?.textContent ||
document.querySelector(".liquidity")?.textContent ||
undefined;
return {
chain,
pairAddress,
tokenSymbol,
price,
volume24h,
liquidity,
};
}
/**
* Extract token mentions from Twitter/X
* Detects $TOKEN format (e.g., $BONK, $SOL)
*/
function extractTwitterTokens(): TokenData[] {
const tokens: TokenData[] = [];
const pageText = document.body.innerText;
// Match $TOKEN pattern (e.g., $BONK, $SOL, $PEPE)
const tokenPattern = /\$([A-Z]{2,10})\b/g;
const matches = pageText.matchAll(tokenPattern);
const uniqueTokens = new Set<string>();
for (const match of matches) {
const symbol = match[1];
if (!uniqueTokens.has(symbol)) {
uniqueTokens.add(symbol);
tokens.push({
chain: "solana", // Default to Solana, can be enhanced
pairAddress: "", // Will be resolved via API
tokenSymbol: symbol,
});
}
}
return tokens;
}
/**
* Extract contract addresses from page content
* Supports Solana and Ethereum address formats
*/
function extractContractAddresses(): TokenData[] {
const tokens: TokenData[] = [];
const pageText = document.body.innerText;
// Solana address pattern (base58, 32-44 characters)
const solanaPattern = /\b([1-9A-HJ-NP-Za-km-z]{32,44})\b/g;
// Ethereum address pattern (0x followed by 40 hex characters)
const ethPattern = /\b(0x[a-fA-F0-9]{40})\b/g;
// Extract Ethereum addresses
const ethMatches = pageText.matchAll(ethPattern);
for (const match of ethMatches) {
const address = match[1];
tokens.push({
chain: "ethereum",
pairAddress: address,
tokenSymbol: undefined,
});
}
// Extract Solana addresses (more selective to avoid false positives)
const solanaMatches = pageText.matchAll(solanaPattern);
const uniqueSolanaAddresses = new Set<string>();
for (const match of solanaMatches) {
const address = match[1];
// Basic validation: should not be all same character, should have variety
if (address.length >= 32 &&
address.length <= 44 &&
new Set(address).size > 10 &&
!uniqueSolanaAddresses.has(address)) {
uniqueSolanaAddresses.add(address);
tokens.push({
chain: "solana",
pairAddress: address,
tokenSymbol: undefined,
});
}
}
return tokens.slice(0, 5); // Limit to first 5 to avoid spam
}
/**
* Extract trading pairs from page content
* Detects patterns like TOKEN/SOL, TOKEN/USDT, etc.
*/
function extractTradingPairs(): TokenData[] {
const tokens: TokenData[] = [];
const pageText = document.body.innerText;
// Match trading pair patterns (e.g., BONK/SOL, PEPE/USDT)
const pairPattern = /\b([A-Z]{2,10})\/([A-Z]{2,10})\b/g;
const matches = pageText.matchAll(pairPattern);
const uniquePairs = new Set<string>();
for (const match of matches) {
const baseToken = match[1];
const quoteToken = match[2];
const pairKey = `${baseToken}/${quoteToken}`;
if (!uniquePairs.has(pairKey)) {
uniquePairs.add(pairKey);
tokens.push({
chain: "solana", // Default to Solana
pairAddress: "", // Will be resolved via API
tokenSymbol: baseToken,
});
}
}
return tokens.slice(0, 3); // Limit to first 3 pairs
}
interface PageContext {
url: string;
title: string;
pageType: PageType;
tokenData?: TokenData;
/** Detected tokens from page content (Twitter mentions, addresses, pairs) */
detectedTokens?: TokenData[];
}
/**
* Extract page context based on page type
*/
function extractPageContext(): PageContext {
const url = window.location.href;
const title = document.title;
const pageType = detectPageType(url);
const context: PageContext = {
url,
title,
pageType,
};
// Add page-specific data
if (pageType === "dexscreener") {
context.tokenData = extractDexScreenerData();
} else if (pageType === "twitter") {
// Extract Twitter token mentions
const twitterTokens = extractTwitterTokens();
const contractAddresses = extractContractAddresses();
const tradingPairs = extractTradingPairs();
// Combine all detected tokens
context.detectedTokens = [
...twitterTokens,
...contractAddresses,
...tradingPairs,
];
// Set primary token if available
if (context.detectedTokens.length > 0) {
context.tokenData = context.detectedTokens[0];
}
} else if (pageType === "generic") {
// For generic pages, try to detect contract addresses and trading pairs
const contractAddresses = extractContractAddresses();
const tradingPairs = extractTradingPairs();
context.detectedTokens = [
...contractAddresses,
...tradingPairs,
];
// Set primary token if available
if (context.detectedTokens.length > 0) {
context.tokenData = context.detectedTokens[0];
}
}
return context;
}
/**
* Send context update to side panel
*/
function sendContextUpdate() {
const context = extractPageContext();
chrome.runtime.sendMessage({
type: "PAGE_CONTEXT_UPDATE",
data: context,
});
}
// Send initial context after page load
if (document.readyState === "complete") {
sendContextUpdate();
} else {
window.addEventListener("load", sendContextUpdate);
}
// Listen for context requests from side panel
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_PAGE_CONTEXT") {
sendContextUpdate();
}
});
// Watch for DOM changes (for dynamic content like DexScreener)
const observer = new MutationObserver(() => {
// Debounce updates
clearTimeout((window as any).__contextUpdateTimeout);
(window as any).__contextUpdateTimeout = setTimeout(sendContextUpdate, 1000);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});

View file

@ -0,0 +1,206 @@
import type { PlasmoCSConfig, PlasmoGetInlineAnchor, PlasmoGetStyle } from "plasmo";
import { Sparkles, X } from "lucide-react";
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
/**
* Floating Quick Action Button (like Mevx)
* Appears on crypto-related pages for quick token analysis
*/
export const config: PlasmoCSConfig = {
matches: [
"*://dexscreener.com/*",
"*://www.dexscreener.com/*",
"*://twitter.com/*",
"*://x.com/*",
"*://coingecko.com/*",
"*://www.coingecko.com/*",
"*://coinmarketcap.com/*",
"*://www.coinmarketcap.com/*",
],
};
export const getStyle: PlasmoGetStyle = () => {
const style = document.createElement("style");
style.textContent = `
#surfsense-floating-button {
all: initial;
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#surfsense-floating-popup {
all: initial;
position: fixed;
bottom: 88px;
right: 24px;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
`;
return style;
};
interface TokenQuickInfo {
symbol: string;
name: string;
price: string;
change24h: number;
chain: string;
}
function FloatingButton() {
const [isOpen, setIsOpen] = useState(false);
const [tokenInfo, setTokenInfo] = useState<TokenQuickInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// Listen for token detection from content script
const handleMessage = (message: any) => {
if (message.type === "TOKEN_DETECTED") {
setTokenInfo(message.data);
}
};
chrome.runtime.onMessage.addListener(handleMessage);
return () => chrome.runtime.onMessage.removeListener(handleMessage);
}, []);
const handleButtonClick = async () => {
if (!isOpen) {
setIsLoading(true);
// Simulate fetching quick token info
setTimeout(() => {
setTokenInfo({
symbol: "BONK",
name: "Bonk",
price: "$0.00001234",
change24h: 156.7,
chain: "Solana",
});
setIsLoading(false);
}, 500);
}
setIsOpen(!isOpen);
};
const handleOpenSidepanel = () => {
chrome.runtime.sendMessage({ type: "OPEN_SIDEPANEL" });
setIsOpen(false);
};
return (
<>
{/* Floating Button */}
<div id="surfsense-floating-button">
<button
onClick={handleButtonClick}
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
border: "none",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
color: "white",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.1)";
e.currentTarget.style.boxShadow = "0 6px 16px rgba(0, 0, 0, 0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)";
}}
>
{isOpen ? <X size={24} /> : <Sparkles size={24} />}
</button>
</div>
{/* Quick Info Popup */}
{isOpen && (
<div id="surfsense-floating-popup">
<div
style={{
width: "320px",
background: "white",
borderRadius: "12px",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.15)",
padding: "16px",
border: "1px solid #e5e7eb",
}}
>
{isLoading ? (
<div style={{ textAlign: "center", padding: "20px", color: "#6b7280" }}>
Loading...
</div>
) : tokenInfo ? (
<>
<div style={{ marginBottom: "12px" }}>
<div style={{ fontSize: "18px", fontWeight: "600", color: "#111827" }}>
{tokenInfo.symbol}
</div>
<div style={{ fontSize: "14px", color: "#6b7280" }}>{tokenInfo.name}</div>
</div>
<div style={{ marginBottom: "16px" }}>
<div style={{ fontSize: "24px", fontWeight: "700", color: "#111827" }}>
{tokenInfo.price}
</div>
<div
style={{
fontSize: "14px",
color: tokenInfo.change24h >= 0 ? "#10b981" : "#ef4444",
fontWeight: "500",
}}
>
{tokenInfo.change24h >= 0 ? "+" : ""}
{tokenInfo.change24h.toFixed(2)}% (24h)
</div>
</div>
<button
onClick={handleOpenSidepanel}
style={{
width: "100%",
padding: "10px",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
border: "none",
borderRadius: "8px",
fontSize: "14px",
fontWeight: "500",
cursor: "pointer",
transition: "opacity 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.9";
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1";
}}
>
Full Analysis
</button>
</>
) : (
<div style={{ textAlign: "center", padding: "20px", color: "#6b7280" }}>
No token detected
</div>
)}
</div>
</div>
)}
</>
);
}
export default FloatingButton;

View file

@ -4,18 +4,25 @@
"version": "0.0.15",
"description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter",
"engines": {
"node": ">=18.0.0 <23.0.0",
"pnpm": ">=8.0.0"
},
"pnpm": {
"overrides": {
"sharp": "^0.33.5"
}
},
"ignoredBuiltDependencies": [
"@swc/core",
"esbuild",
"lmdb",
"msgpackr-extract",
"sharp"
],
"onlyBuiltDependencies": [
"@parcel/watcher"
]
},
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"build": "plasmo build && node scripts/fix-build-paths.js",
"build:raw": "plasmo build",
"package": "plasmo package"
},
"dependencies": {
@ -33,7 +40,6 @@
"dom-to-semantic-markdown": "^1.2.11",
"linkedom": "0.1.34",
"lucide-react": "^0.454.0",
"plasmo": "0.90.5",
"postcss-loader": "^8.1.1",
"radix-ui": "^1.0.1",
"react": "18.2.0",
@ -51,6 +57,7 @@
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"autoprefixer": "^10.4.20",
"plasmo": "0.90.5",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "5.3.3"
@ -61,12 +68,44 @@
],
"name": "SurfSense",
"description": "Extension to collect Browsing History for SurfSense.",
"version": "0.0.3"
"version": "0.0.3",
"commands": {
"analyze-token": {
"suggested_key": {
"default": "Ctrl+Shift+A",
"mac": "Command+Shift+A"
},
"description": "Analyze current token"
},
"add-watchlist": {
"suggested_key": {
"default": "Ctrl+Shift+W",
"mac": "Command+Shift+W"
},
"description": "Add token to watchlist"
},
"capture-page": {
"suggested_key": {
"default": "Ctrl+Shift+E",
"mac": "Command+Shift+E"
},
"description": "Capture current page"
},
"show-portfolio": {
"suggested_key": {
"default": "Ctrl+Shift+P",
"mac": "Command+Shift+P"
},
"description": "Show portfolio"
}
}
},
"permissions": [
"storage",
"scripting",
"unlimitedStorage",
"activeTab"
"activeTab",
"sidePanel",
"contextMenus"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "@/routes/ui/toaster";
import { Routing } from "~routes";
import { Routing } from "./routes";
function IndexPopup() {
return (

View file

@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Post-build script to fix absolute paths in Plasmo-generated HTML files.
*
* Problem: Plasmo generates HTML files with absolute paths (e.g., href="/file.css")
* which don't work in Chrome extensions (ERR_FILE_NOT_FOUND).
*
* Solution: Replace absolute paths with relative paths (e.g., href="./file.css")
*/
const fs = require('fs');
const path = require('path');
const BUILD_DIR = path.join(__dirname, '..', 'build', 'chrome-mv3-prod');
const HTML_FILES = ['popup.html', 'sidepanel.html'];
function fixPaths(filePath) {
if (!fs.existsSync(filePath)) {
console.log(`⚠️ File not found: ${filePath}`);
return false;
}
let content = fs.readFileSync(filePath, 'utf8');
const originalContent = content;
// Replace absolute paths with relative paths
// href="/something" -> href="./something"
// src="/something" -> src="./something"
content = content.replace(/href="\/([^"]+)"/g, 'href="./$1"');
content = content.replace(/src="\/([^"]+)"/g, 'src="./$1"');
if (content !== originalContent) {
fs.writeFileSync(filePath, content, 'utf8');
console.log(`✅ Fixed paths in: ${path.basename(filePath)}`);
return true;
} else {
console.log(` No changes needed: ${path.basename(filePath)}`);
return false;
}
}
function main() {
console.log('🔧 Fixing build paths for Chrome extension...\n');
if (!fs.existsSync(BUILD_DIR)) {
console.error(`❌ Build directory not found: ${BUILD_DIR}`);
console.error(' Run "pnpm build" first.');
process.exit(1);
}
let fixedCount = 0;
for (const htmlFile of HTML_FILES) {
const filePath = path.join(BUILD_DIR, htmlFile);
if (fixPaths(filePath)) {
fixedCount++;
}
}
console.log(`\n✨ Done! Fixed ${fixedCount} file(s).`);
}
main();

View file

@ -0,0 +1,16 @@
import { SidePanelApp } from "./sidepanel/index";
import "./tailwind.css";
/**
* Side Panel entry point for SurfSense Extension
* Opens as a Chrome Side Panel (not popup) for better UX
*/
function IndexSidePanel() {
return (
<div className="h-screen w-full bg-background text-foreground">
<SidePanelApp />
</div>
);
}
export default IndexSidePanel;

View file

@ -0,0 +1,385 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
TrendingUp,
TrendingDown,
CheckCircle,
XCircle,
AlertTriangle,
RefreshCw,
ExternalLink,
Shield,
Users,
Droplet,
BarChart3,
MessageSquare,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface TokenAnalysisData {
tokenAddress: string;
tokenSymbol: string;
tokenName: string;
chain: string;
timestamp: Date;
contract: {
verified: boolean;
renounced: boolean;
isProxy: boolean;
sourceCode: boolean;
};
holders: {
count: number;
top10Percent: number;
distribution: { address: string; percent: number }[];
};
liquidity: {
totalUSD: number;
lpLocked: boolean;
lpLockDuration?: number;
liquidityMcapRatio: number;
};
volume: {
volume24h: number;
trend: "increasing" | "decreasing" | "stable";
volumeLiquidityRatio: number;
};
price: {
current: number;
ath: number;
atl: number;
change7d: number;
change30d: number;
volatility: number;
};
social: {
twitterMentions: number;
telegramActivity: number;
redditDiscussions: number;
sentimentScore: number; // -1 to 1
sentiment: "positive" | "negative" | "neutral";
};
aiSummary: string;
recommendation: "buy" | "hold" | "sell" | "avoid";
confidence: number; // 0-100
}
export interface TokenAnalysisPanelProps {
/** Token analysis data */
analysis: TokenAnalysisData;
/** Callback when refresh is clicked */
onRefresh?: () => void;
/** Callback when "View Full Report" is clicked */
onViewFullReport?: () => void;
/** Whether data is loading */
isLoading?: boolean;
/** Additional class names */
className?: string;
}
/**
* TokenAnalysisPanel - Comprehensive token analysis display
*
* Features:
* - AI-generated summary with recommendation
* - Contract analysis (verified, renounced, proxy)
* - Holder distribution analysis
* - Liquidity analysis with LP lock status
* - Volume trends and trading activity
* - Price history and volatility
* - Social sentiment analysis
*/
export function TokenAnalysisPanel({
analysis,
onRefresh,
onViewFullReport,
isLoading = false,
className,
}: TokenAnalysisPanelProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await onRefresh?.();
setTimeout(() => setIsRefreshing(false), 1000);
};
const formatTimeAgo = (date: Date) => {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
};
const formatCurrency = (value: number) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
return `$${value.toFixed(0)}`;
};
const getRecommendationColor = (rec: string) => {
switch (rec) {
case "buy": return "text-green-600 dark:text-green-400";
case "hold": return "text-yellow-600 dark:text-yellow-400";
case "sell": return "text-orange-600 dark:text-orange-400";
case "avoid": return "text-red-600 dark:text-red-400";
default: return "text-muted-foreground";
}
};
const getSentimentEmoji = (sentiment: string) => {
switch (sentiment) {
case "positive": return "😊";
case "negative": return "😟";
case "neutral": return "😐";
default: return "🤔";
}
};
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">
<BarChart3 className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">Token Analysis</h2>
<p className="text-xs text-muted-foreground">
{analysis.tokenSymbol} <ChainIcon chain={analysis.chain} size="xs" className="inline" />
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* AI Summary */}
<div className="p-4 bg-primary/5 rounded-lg border border-primary/20">
<div className="flex items-start gap-2 mb-2">
<div className="text-lg">🤖</div>
<div className="flex-1">
<h3 className="font-semibold text-sm mb-1">AI Summary</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{analysis.aiSummary}
</p>
</div>
</div>
{/* Recommendation */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-primary/10">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Recommendation:</span>
<span className={cn("font-bold text-sm uppercase", getRecommendationColor(analysis.recommendation))}>
{analysis.recommendation}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Confidence:</span>
<span className="font-semibold text-sm">{analysis.confidence}%</span>
</div>
</div>
</div>
{/* Contract Analysis */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Contract</h3>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
{analysis.contract.verified ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="text-xs">Verified</span>
</div>
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
{analysis.contract.renounced ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="text-xs">Renounced</span>
</div>
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
{!analysis.contract.isProxy ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<AlertTriangle className="h-4 w-4 text-yellow-600" />
)}
<span className="text-xs">{analysis.contract.isProxy ? "Proxy" : "Direct"}</span>
</div>
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
{analysis.contract.sourceCode ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="text-xs">Source Code</span>
</div>
</div>
</div>
{/* Holder Distribution */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Holders</h3>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="text-xs text-muted-foreground">Total Holders</span>
<span className="font-semibold text-sm">{analysis.holders.count.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="text-xs text-muted-foreground">Top 10 Holdings</span>
<span className={cn(
"font-semibold text-sm",
analysis.holders.top10Percent > 50 ? "text-red-600" :
analysis.holders.top10Percent > 30 ? "text-yellow-600" :
"text-green-600"
)}>
{analysis.holders.top10Percent}%
</span>
</div>
</div>
</div>
{/* Liquidity */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Droplet className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Liquidity</h3>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="text-xs text-muted-foreground">Total Liquidity</span>
<span className="font-semibold text-sm">{formatCurrency(analysis.liquidity.totalUSD)}</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="text-xs text-muted-foreground">LP Lock Status</span>
<div className="flex items-center gap-1">
{analysis.liquidity.lpLocked ? (
<>
<CheckCircle className="h-3 w-3 text-green-600" />
<span className="font-semibold text-xs text-green-600">
{analysis.liquidity.lpLockDuration ? `${analysis.liquidity.lpLockDuration}d` : "Locked"}
</span>
</>
) : (
<>
<XCircle className="h-3 w-3 text-red-600" />
<span className="font-semibold text-xs text-red-600">Unlocked</span>
</>
)}
</div>
</div>
</div>
</div>
{/* Volume & Price */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<h3 className="font-semibold text-xs text-muted-foreground">Volume 24h</h3>
<div className="flex items-center gap-2">
<span className="font-bold text-lg">{formatCurrency(analysis.volume.volume24h)}</span>
{analysis.volume.trend === "increasing" ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : analysis.volume.trend === "decreasing" ? (
<TrendingDown className="h-4 w-4 text-red-600" />
) : null}
</div>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-xs text-muted-foreground">Price</h3>
<div className="space-y-1">
<div className="font-bold text-lg">${analysis.price.current.toFixed(8)}</div>
<div className="flex gap-2 text-xs">
<span className={cn(
analysis.price.change7d >= 0 ? "text-green-600" : "text-red-600"
)}>
7d: {analysis.price.change7d >= 0 ? "+" : ""}{analysis.price.change7d.toFixed(1)}%
</span>
<span className={cn(
analysis.price.change30d >= 0 ? "text-green-600" : "text-red-600"
)}>
30d: {analysis.price.change30d >= 0 ? "+" : ""}{analysis.price.change30d.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
{/* Social Sentiment */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Social Sentiment</h3>
</div>
<div className="p-3 bg-muted/50 rounded">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
{getSentimentEmoji(analysis.social.sentiment)} {analysis.social.sentiment.charAt(0).toUpperCase() + analysis.social.sentiment.slice(1)}
</span>
<span className="text-xs text-muted-foreground">
Score: {(analysis.social.sentimentScore * 100).toFixed(0)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-muted-foreground">Twitter</div>
<div className="font-semibold">{analysis.social.twitterMentions}</div>
</div>
<div>
<div className="text-muted-foreground">Telegram</div>
<div className="font-semibold">{analysis.social.telegramActivity}</div>
</div>
<div>
<div className="text-muted-foreground">Reddit</div>
<div className="font-semibold">{analysis.social.redditDiscussions}</div>
</div>
</div>
</div>
</div>
{/* Last Updated */}
<div className="text-xs text-muted-foreground text-center">
Last updated: {formatTimeAgo(analysis.timestamp)}
</div>
</div>
{/* Footer */}
<div className="border-t p-3">
<Button
variant="default"
className="w-full"
onClick={onViewFullReport}
>
<ExternalLink className="h-4 w-4 mr-2" />
View Full Report
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,282 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
TrendingUp,
TrendingDown,
Target,
AlertCircle,
Info,
DollarSign,
Percent,
Clock,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface TradingSuggestion {
tokenAddress: string;
tokenSymbol: string;
tokenName: string;
chain: string;
currentPrice: number;
timestamp: Date;
entry: {
min: number;
max: number;
reasoning: string;
};
targets: {
level: number;
price: number;
percentGain: number;
confidence: number;
}[];
stopLoss: {
price: number;
percentLoss: number;
reasoning: string;
};
riskReward: number;
overallConfidence: number;
technicalLevels: {
support: number[];
resistance: number[];
};
reasoning: string[];
invalidationConditions: string[];
}
export interface TradingSuggestionPanelProps {
/** Trading suggestion data */
suggestion: TradingSuggestion;
/** Callback when "Set Alerts" is clicked */
onSetAlerts?: () => void;
/** Callback when "View Chart" is clicked */
onViewChart?: () => void;
/** Additional class names */
className?: string;
}
/**
* TradingSuggestionPanel - AI-powered entry/exit suggestions
*
* Features:
* - Entry zone recommendations
* - Multiple take-profit targets
* - Stop-loss suggestions
* - Risk/reward ratio calculation
* - Technical analysis levels
* - AI reasoning and invalidation conditions
*/
export function TradingSuggestionPanel({
suggestion,
onSetAlerts,
onViewChart,
className,
}: TradingSuggestionPanelProps) {
const [showDetails, setShowDetails] = useState(false);
const formatPrice = (price: number) => {
if (price < 0.01) return `$${price.toFixed(8)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toFixed(4)}`;
};
const getRiskRewardColor = (ratio: number) => {
if (ratio >= 3) return "text-green-600 dark:text-green-400";
if (ratio >= 2) return "text-yellow-600 dark:text-yellow-400";
return "text-red-600 dark:text-red-400";
};
const getRiskRewardLabel = (ratio: number) => {
if (ratio >= 3) return "Excellent";
if (ratio >= 2) return "Good";
if (ratio >= 1.5) return "Fair";
return "Poor";
};
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">
<Target className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">Trading Suggestion</h2>
<p className="text-xs text-muted-foreground">
{suggestion.tokenSymbol} <ChainIcon chain={suggestion.chain} size="xs" className="inline" />
</p>
</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground">Confidence</div>
<div className="font-bold text-sm">{suggestion.overallConfidence}%</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Current Price */}
<div className="p-3 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground mb-1">Current Price</div>
<div className="font-bold text-2xl">{formatPrice(suggestion.currentPrice)}</div>
</div>
{/* Entry Zone */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<h3 className="font-semibold text-sm">Entry Zone</h3>
</div>
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-lg text-green-600 dark:text-green-400">
{formatPrice(suggestion.entry.min)} - {formatPrice(suggestion.entry.max)}
</span>
</div>
<p className="text-xs text-muted-foreground">{suggestion.entry.reasoning}</p>
</div>
</div>
{/* Targets */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Take Profit Targets</h3>
</div>
<div className="space-y-2">
{suggestion.targets.map((target) => (
<div
key={target.level}
className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm">🎯 Target {target.level}</span>
<span className="text-xs text-muted-foreground">
Confidence: {target.confidence}%
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-bold text-blue-600 dark:text-blue-400">
{formatPrice(target.price)}
</span>
<span className="font-semibold text-sm text-green-600 dark:text-green-400">
+{target.percentGain.toFixed(1)}%
</span>
</div>
</div>
))}
</div>
</div>
{/* Stop Loss */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<h3 className="font-semibold text-sm">Stop Loss</h3>
</div>
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-lg text-red-600 dark:text-red-400">
{formatPrice(suggestion.stopLoss.price)}
</span>
<span className="font-semibold text-sm text-red-600 dark:text-red-400">
{suggestion.stopLoss.percentLoss.toFixed(1)}%
</span>
</div>
<p className="text-xs text-muted-foreground">{suggestion.stopLoss.reasoning}</p>
</div>
</div>
{/* Risk/Reward */}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Risk/Reward Ratio</span>
<span className={cn("font-bold text-lg", getRiskRewardColor(suggestion.riskReward))}>
1:{suggestion.riskReward.toFixed(1)}
</span>
</div>
<div className="flex items-center gap-2">
<div className={cn(
"px-2 py-1 rounded text-xs font-medium",
suggestion.riskReward >= 3 ? "bg-green-500/20 text-green-600 dark:text-green-400" :
suggestion.riskReward >= 2 ? "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400" :
"bg-red-500/20 text-red-600 dark:text-red-400"
)}>
{getRiskRewardLabel(suggestion.riskReward)}
</div>
</div>
</div>
{/* Why? Section */}
<div className="space-y-2">
<button
className="flex items-center gap-2 w-full"
onClick={() => setShowDetails(!showDetails)}
>
<Info className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Why?</h3>
<div className={cn(
"ml-auto transition-transform",
showDetails && "rotate-180"
)}>
</div>
</button>
{showDetails && (
<div className="space-y-3 pl-6">
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Reasoning:</h4>
<ul className="space-y-1">
{suggestion.reasoning.map((reason, i) => (
<li key={i} className="text-xs flex items-start gap-2">
<span className="text-green-600 dark:text-green-400"></span>
<span>{reason}</span>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Invalidation Conditions:</h4>
<ul className="space-y-1">
{suggestion.invalidationConditions.map((condition, i) => (
<li key={i} className="text-xs flex items-start gap-2">
<AlertCircle className="h-3 w-3 text-red-600 dark:text-red-400 mt-0.5" />
<span>{condition}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
</div>
{/* Footer Actions */}
<div className="border-t p-3 space-y-2">
<Button
variant="default"
className="w-full"
onClick={onSetAlerts}
>
Set Alerts for These Levels
</Button>
<Button
variant="outline"
className="w-full"
onClick={onViewChart}
>
View Chart
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
Minus,
ArrowRight,
Type,
Circle,
Square,
TrendingUp,
Eraser,
Undo,
Redo,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
export type AnnotationType = "line" | "arrow" | "text" | "circle" | "rectangle" | "fibonacci";
export interface Annotation {
id: string;
type: AnnotationType;
coordinates: { x: number; y: number }[];
text?: string;
color: string;
}
export interface AnnotationToolsProps {
/** Callback when annotation is added */
onAnnotationAdd?: (annotation: Annotation) => void;
/** Callback when annotation is removed */
onAnnotationRemove?: (id: string) => void;
/** Callback when undo is clicked */
onUndo?: () => void;
/** Callback when redo is clicked */
onRedo?: () => void;
/** Additional class names */
className?: string;
}
/**
* AnnotationTools - Drawing tools for chart annotations
*
* Features:
* - Line tool for trend lines, support/resistance
* - Arrow tool for directional indicators
* - Text tool for labels
* - Shape tools (circle, rectangle)
* - Fibonacci retracement tool
* - Color picker
* - Undo/Redo functionality
*/
export function AnnotationTools({
onAnnotationAdd,
onAnnotationRemove,
onUndo,
onRedo,
className,
}: AnnotationToolsProps) {
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null);
const [selectedColor, setSelectedColor] = useState("#3b82f6"); // blue-500
const tools: { type: AnnotationType; icon: any; label: string }[] = [
{ type: "line", icon: Minus, label: "Line" },
{ type: "arrow", icon: ArrowRight, label: "Arrow" },
{ type: "text", icon: Type, label: "Text" },
{ type: "circle", icon: Circle, label: "Circle" },
{ type: "rectangle", icon: Square, label: "Rectangle" },
{ type: "fibonacci", icon: TrendingUp, label: "Fibonacci" },
];
const colors = [
{ value: "#3b82f6", label: "Blue" },
{ value: "#ef4444", label: "Red" },
{ value: "#22c55e", label: "Green" },
{ value: "#eab308", label: "Yellow" },
{ value: "#a855f7", label: "Purple" },
{ value: "#ffffff", label: "White" },
];
const handleToolSelect = (tool: AnnotationType) => {
setSelectedTool(tool === selectedTool ? null : tool);
};
return (
<div className={cn("space-y-3", className)}>
{/* Drawing Tools */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground">Drawing Tools</h4>
<div className="grid grid-cols-3 gap-2">
{tools.map(({ type, icon: Icon, label }) => (
<button
key={type}
className={cn(
"flex flex-col items-center gap-1 p-2 rounded border transition-colors",
selectedTool === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted hover:bg-muted/80"
)}
onClick={() => handleToolSelect(type)}
>
<Icon className="h-4 w-4" />
<span className="text-xs">{label}</span>
</button>
))}
</div>
</div>
{/* Color Picker */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground">Color</h4>
<div className="flex gap-2">
{colors.map(({ value, label }) => (
<button
key={value}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all",
selectedColor === value
? "border-primary scale-110"
: "border-muted hover:scale-105"
)}
style={{ backgroundColor: value }}
onClick={() => setSelectedColor(value)}
title={label}
/>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onUndo}
>
<Undo className="h-3 w-3 mr-1" />
Undo
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onRedo}
>
<Redo className="h-3 w-3 mr-1" />
Redo
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedTool(null)}
>
<Eraser className="h-3 w-3" />
</Button>
</div>
{/* Instructions */}
{selectedTool && (
<div className="p-2 bg-muted/50 rounded text-xs text-muted-foreground">
{selectedTool === "line" && "Click and drag to draw a line"}
{selectedTool === "arrow" && "Click and drag to draw an arrow"}
{selectedTool === "text" && "Click to add text label"}
{selectedTool === "circle" && "Click and drag to draw a circle"}
{selectedTool === "rectangle" && "Click and drag to draw a rectangle"}
{selectedTool === "fibonacci" && "Click two points to draw Fibonacci retracement"}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,271 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
Camera,
Download,
Copy,
Twitter,
MessageSquare,
Image as ImageIcon,
Palette,
Settings,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { AnnotationTools } from "./AnnotationTools";
export interface ChartCaptureMetadata {
tokenSymbol: string;
tokenName: string;
price: number;
change24h: number;
volume: number;
liquidity: number;
timestamp: Date;
}
export interface ChartCaptureSettings {
style: "dark" | "light" | "neon";
includeTokenInfo: boolean;
includePriceChange: boolean;
includeVolumeLiquidity: boolean;
includeTimestamp: boolean;
includeWatermark: boolean;
}
export interface ChartCapturePanelProps {
/** Current token metadata */
metadata?: ChartCaptureMetadata;
/** Callback when capture is clicked */
onCapture?: () => void;
/** Callback when export is clicked */
onExport?: (format: "twitter" | "telegram" | "instagram" | "clipboard") => void;
/** Additional class names */
className?: string;
}
/**
* ChartCapturePanel - Chart screenshot tool with annotations
*
* Features:
* - One-click chart capture from DexScreener
* - Auto-add metadata overlay (token info, price, volume, etc.)
* - Drawing tools (lines, arrows, text, shapes, Fibonacci)
* - Template styles (dark, light, neon)
* - Export options (Twitter, Telegram, Instagram, clipboard)
*/
export function ChartCapturePanel({
metadata,
onCapture,
onExport,
className,
}: ChartCapturePanelProps) {
const [settings, setSettings] = useState<ChartCaptureSettings>({
style: "dark",
includeTokenInfo: true,
includePriceChange: true,
includeVolumeLiquidity: true,
includeTimestamp: true,
includeWatermark: false,
});
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const handleCapture = async () => {
setIsCapturing(true);
await onCapture?.();
// Mock: simulate capture
setTimeout(() => {
setCapturedImage("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
setIsCapturing(false);
}, 1000);
};
const handleExport = (format: "twitter" | "telegram" | "instagram" | "clipboard") => {
onExport?.(format);
};
const formatCurrency = (value: number) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
return `$${value.toFixed(2)}`;
};
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">
<Camera className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">Chart Capture</h2>
{metadata && (
<p className="text-xs text-muted-foreground">
{metadata.tokenSymbol}
</p>
)}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Capture Button */}
{!capturedImage && (
<Button
variant="default"
className="w-full"
onClick={handleCapture}
disabled={isCapturing}
>
<Camera className="h-4 w-4 mr-2" />
{isCapturing ? "Capturing..." : "Capture Chart"}
</Button>
)}
{/* Preview */}
{capturedImage && (
<div className="space-y-3">
<div className="relative border rounded-lg overflow-hidden bg-muted/50">
<img
src={capturedImage}
alt="Captured chart"
className="w-full h-auto"
/>
{/* Metadata Overlay Preview */}
{metadata && settings.includeTokenInfo && (
<div className="absolute top-2 left-2 bg-background/90 backdrop-blur-sm p-2 rounded text-xs">
<div className="font-bold">{metadata.tokenSymbol}</div>
{settings.includePriceChange && (
<div className={cn(
"font-semibold",
metadata.change24h >= 0 ? "text-green-600" : "text-red-600"
)}>
${metadata.price.toFixed(6)} ({metadata.change24h >= 0 ? "+" : ""}{metadata.change24h.toFixed(2)}%)
</div>
)}
</div>
)}
</div>
{/* Annotation Tools */}
<AnnotationTools />
{/* Recapture Button */}
<Button
variant="outline"
className="w-full"
onClick={() => setCapturedImage(null)}
>
<Camera className="h-4 w-4 mr-2" />
Recapture
</Button>
</div>
)}
{/* Style Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Style</h3>
</div>
<div className="flex gap-2">
{(["dark", "light", "neon"] as const).map((style) => (
<button
key={style}
className={cn(
"flex-1 p-2 rounded border text-xs font-medium transition-colors",
settings.style === style
? "bg-primary text-primary-foreground border-primary"
: "bg-muted hover:bg-muted/80"
)}
onClick={() => setSettings({ ...settings, style })}
>
{style.charAt(0).toUpperCase() + style.slice(1)}
</button>
))}
</div>
</div>
{/* Metadata Options */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Metadata</h3>
</div>
<div className="space-y-2">
{[
{ key: "includeTokenInfo" as const, label: "Token info" },
{ key: "includePriceChange" as const, label: "Price & change" },
{ key: "includeVolumeLiquidity" as const, label: "Volume & liquidity" },
{ key: "includeTimestamp" as const, label: "Timestamp" },
{ key: "includeWatermark" as const, label: "Watermark" },
].map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings[key]}
onChange={(e) =>
setSettings({ ...settings, [key]: e.target.checked })
}
className="rounded"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
</div>
</div>
{/* Footer - Export Options */}
{capturedImage && (
<div className="border-t p-3 space-y-2">
<h3 className="font-semibold text-xs text-muted-foreground mb-2">Export</h3>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleExport("twitter")}
>
<Twitter className="h-3 w-3 mr-1" />
Twitter
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("telegram")}
>
<MessageSquare className="h-3 w-3 mr-1" />
Telegram
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("instagram")}
>
<ImageIcon className="h-3 w-3 mr-1" />
Instagram
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("clipboard")}
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</div>
<Button
variant="default"
className="w-full"
onClick={() => handleExport("clipboard")}
>
<Download className="h-4 w-4 mr-2" />
Save to File
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,283 @@
import { useState } from "react";
import {
Settings,
ChevronDown,
User,
LogOut,
ExternalLink,
Star,
Bell,
MessageSquare,
Plug,
Search,
X
} 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;
/** Callback when token search is triggered */
onTokenSearch?: (query: string) => void;
}
/**
* Enhanced Chat header with branding, token search, space selector, settings, and user menu
*
* Features:
* - Universal token search bar (works on any page)
* - Search space selector dropdown
* - Settings dropdown with full menu
* - User avatar with logout option
*/
export function ChatHeader({
searchSpaces = [],
selectedSpace,
onSpaceChange,
userName,
userAvatar,
onLogout,
onSettingsClick,
onTokenSearch,
}: ChatHeaderProps) {
const [spaceOpen, setSpaceOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
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];
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim() && onTokenSearch) {
onTokenSearch(searchQuery.trim());
}
};
const handleClearSearch = () => {
setSearchQuery("");
};
return (
<div className="flex flex-col border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{/* Top row: Logo, Space Selector, Settings */}
<div className="flex items-center justify-between p-3 pb-2">
{/* 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-base">SurfSense</h1>
</div>
{/* 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>
{/* Bottom row: Token Search Bar */}
<div className="px-3 pb-2">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder="Search token (symbol, name, or address)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-8 pl-9 pr-8 text-sm rounded-md border border-input bg-background/50 focus:bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted transition-colors"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</form>
</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

@ -0,0 +1,256 @@
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 {
/** 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;
}
/**
* 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,
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() || 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 (
<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

@ -0,0 +1,648 @@
import { useState, useEffect, useCallback } from "react";
import { usePageContext } from "../context/PageContextProvider";
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
import { QuickCapture } from "./QuickCapture";
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,
MOCK_WHALE_TRANSACTIONS,
MOCK_TRADING_SUGGESTION,
MOCK_PORTFOLIO,
} from "../mock/mockData";
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";
type ViewMode = "chat" | "watchlist" | "safety";
/**
* Natural language command patterns for conversational UX
*/
const COMMAND_PATTERNS = {
// Epic 1: Basic commands
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,
// Epic 2: Smart Monitoring & Alerts
SHOW_WHALE_ACTIVITY: /(show|display|view)\s+(whale|large)\s+(activity|transactions|trades)/i,
// Epic 3: Trading Intelligence
TRADING_SUGGESTION: /(suggest|recommend|entry|exit|trade)\s+(for\s+)?(\w+)/i,
SHOW_PORTFOLIO: /(show|display|view)\s+(my\s+)?portfolio/i,
// Epic 4: Content Creation & Productivity
CAPTURE_CHART: /(capture|screenshot|snap|grab)\s+(chart|graph)/i,
GENERATE_THREAD: /(generate|create|write)\s+(thread|tweet)/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, 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);
// 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);
setViewMode("chat");
// Add user message
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 },
]);
setTimeout(() => {
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 },
]);
}, 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);
const showWhaleActivityMatch = content.match(COMMAND_PATTERNS.SHOW_WHALE_ACTIVITY);
const tradingSuggestionMatch = content.match(COMMAND_PATTERNS.TRADING_SUGGESTION);
const showPortfolioMatch = content.match(COMMAND_PATTERNS.SHOW_PORTFOLIO);
const captureChartMatch = content.match(COMMAND_PATTERNS.CAPTURE_CHART);
const generateThreadMatch = content.match(COMMAND_PATTERNS.GENERATE_THREAD);
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 (showWhaleActivityMatch || (content.toLowerCase().includes("whale") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("activity")))) {
// Show whale activity command (Epic 2)
responseContent = `Here's recent whale activity for ${tokenSymbol}:`;
widget = {
type: "whale_activity",
transactions: MOCK_WHALE_TRANSACTIONS.slice(0, 5), // Show top 5
};
responseContent += `\n\n🐋 I'm tracking 5 large transactions (>$10K) in the last hour. The smart money wallet 0x742d...35BA just bought $50K worth - this could be a bullish signal!`;
} else if (tradingSuggestionMatch || (content.toLowerCase().includes("suggest") || content.toLowerCase().includes("entry") || content.toLowerCase().includes("exit"))) {
// Trading suggestion command (Epic 3)
const token = tradingSuggestionMatch?.[3] || tokenSymbol;
responseContent = `Here's my trading analysis for ${token}:`;
widget = {
type: "trading_suggestion",
suggestion: MOCK_TRADING_SUGGESTION,
};
responseContent += `\n\n📊 Based on technical analysis, I've identified optimal entry zones and profit targets. The risk/reward ratio of 1:3.3 looks favorable. Would you like me to set price alerts for these levels?`;
} else if (showPortfolioMatch || (content.toLowerCase().includes("portfolio") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view")))) {
// Show portfolio command (Epic 3)
responseContent = `Here's your portfolio overview:`;
widget = {
type: "portfolio",
portfolio: MOCK_PORTFOLIO,
};
responseContent += `\n\n💼 Your portfolio is up $234 (4.7%) in the last 24 hours! BULLA is your best performer at +15%. Want me to analyze if it's time to take profits?`;
} else if (captureChartMatch || (content.toLowerCase().includes("capture") || content.toLowerCase().includes("screenshot")) && content.toLowerCase().includes("chart")) {
// Capture chart command (Epic 4)
responseContent = `I'll help you capture this chart:`;
widget = {
type: "chart_capture",
metadata: {
tokenSymbol: tokenSymbol,
tokenName: context?.tokenData?.tokenName || "Bulla Token",
chain: context?.tokenData?.chain || "solana",
price: context?.tokenData?.price || "$0.00001234",
priceChange24h: 156.7,
volume24h: context?.tokenData?.volume24h || "$1.2M",
liquidity: context?.tokenData?.liquidity || "$450K",
timestamp: new Date(),
},
};
responseContent += `\n\n📸 I've prepared the chart capture tool with auto-filled metadata. Choose your style (dark/light/neon) and export format (Twitter/Telegram/Instagram). The metadata overlay will make your chart look professional!`;
} else if (generateThreadMatch || (content.toLowerCase().includes("generate") || content.toLowerCase().includes("create")) && content.toLowerCase().includes("thread")) {
// Generate thread command (Epic 4)
responseContent = `I'll help you create a Twitter thread about ${tokenSymbol}:`;
widget = {
type: "thread_generator",
tokenAddress: context?.tokenData?.pairAddress,
tokenSymbol: tokenSymbol,
chain: context?.tokenData?.chain || "solana",
};
responseContent += `\n\n🧵 I've prepared the thread generator with token info auto-filled. Choose your tone (bullish/neutral/bearish) and thread length (5-10 tweets). I'll generate a professional thread structure: Hook → Analysis → Implications → Conclusion. You can edit each tweet before posting!`;
} 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:
**Epic 1: Basic 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
**Epic 2: Smart Monitoring**
**"Show whale activity"** - Large transactions (>$10K)
**Epic 3: Trading Intelligence**
**"Suggest entry for BULLA"** - Entry/exit suggestions
**"Show my portfolio"** - Portfolio tracker with P&L
**Epic 4: Content Creation**
**"Capture chart"** - Screenshot with metadata
**"Generate thread"** - AI Twitter thread generator
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"];
// Handle token search from header
const handleTokenSearch = async (query: string) => {
// Add user message
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: `Analyze ${query}`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
// Simulate AI response with token analysis
setTimeout(() => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: `Searching for token: ${query}...`,
timestamp: new Date(),
widget: {
type: "token_analysis",
data: {
symbol: query.toUpperCase(),
name: `${query} Token`,
chain: "solana",
price: "$0.00001234",
priceChange24h: 156.7,
marketCap: "$2.1M",
volume24h: "$1.2M",
liquidity: "$450K",
safetyScore: MOCK_SAFETY_SCORE,
holderCount: 12456,
top10HolderPercent: 35,
},
isInWatchlist: false,
},
};
setMessages((prev) => [...prev, aiMessage]);
}, 500);
};
/**
* Handle detected token click
*/
const handleDetectedTokenClick = (token: TokenData) => {
const query = token.tokenSymbol || token.pairAddress;
handleTokenSearch(query);
};
return (
<div className="flex flex-col h-full">
{/* Header with space selector and settings */}
<ChatHeader
searchSpaces={MOCK_SEARCH_SPACES}
selectedSpace={selectedSpace}
onSpaceChange={handleSpaceChange}
userName={userName}
onSettingsClick={handleSettingsClick}
onLogout={handleLogout}
onTokenSearch={handleTokenSearch}
/>
{/* Token info card (only on DexScreener) */}
{context?.pageType === "dexscreener" && context.tokenData && viewMode === "chat" && (
<TokenInfoCard
tokenData={context.tokenData}
isInWatchlist={isInWatchlist}
onAddToWatchlist={handleAddToWatchlist}
onSafetyCheck={handleSafetyCheck}
onRugCheck={handleRugCheck}
/>
)}
{/* Detected tokens list (on Twitter and other pages) */}
{context?.detectedTokens && context.detectedTokens.length > 0 && viewMode === "chat" && (
<DetectedTokensList
tokens={context.detectedTokens}
onTokenClick={handleDetectedTokenClick}
/>
)}
{/* Main content area */}
<div className="flex-1 overflow-y-auto">
{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 and quick capture (only in chat mode) */}
{viewMode === "chat" && (
<div className="flex-shrink-0">
<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}
/>
<QuickCapture />
</div>
)}
{/* Back to chat button for other views */}
{viewMode !== "chat" && (
<div className="flex-shrink-0 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>
)}
{/* 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

@ -0,0 +1,235 @@
import { WelcomeScreen } from "./WelcomeScreen";
import { cn } from "~/lib/utils";
import {
ActionConfirmationWidget,
ProactiveAlertCard,
WatchlistWidget,
AlertWidget,
TokenAnalysisWidget,
WhaleActivityWidget,
TradingSuggestionWidget,
PortfolioWidget,
ChartCaptureWidget,
ThreadGeneratorWidget,
type ProactiveAlertData,
type WatchlistItem,
type AlertConfigData,
type TokenAnalysisData,
} from "../widgets";
import type { WhaleTransaction } from "../whale/WhaleActivityFeed";
import type { TradingSuggestion } from "../analysis/TradingSuggestionPanel";
import type { PortfolioData } from "../portfolio/PortfolioPanel";
import type { ChartCaptureMetadata } from "../capture/ChartCapturePanel";
// 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 }
| { type: "whale_activity"; transactions: WhaleTransaction[] }
| { type: "trading_suggestion"; suggestion: TradingSuggestion }
| { type: "portfolio"; portfolio: PortfolioData }
| { type: "chart_capture"; metadata?: ChartCaptureMetadata }
| { type: "thread_generator"; tokenAddress?: string; tokenSymbol?: string; chain?: string };
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 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
* - WhaleActivityWidget: Whale transaction feed (Epic 2)
* - TradingSuggestionWidget: Entry/exit suggestions (Epic 3)
* - PortfolioWidget: Portfolio tracker (Epic 3)
* - ChartCaptureWidget: Chart screenshot tool (Epic 4)
* - ThreadGeneratorWidget: Twitter thread generator (Epic 4)
*/
export function ChatMessages({
messages,
onSuggestionClick,
userName,
onWidgetAction,
}: ChatMessagesProps) {
if (messages.length === 0) {
return (
<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)}
/>
);
case "whale_activity":
return (
<WhaleActivityWidget
transactions={widget.transactions}
onTrackWallet={(address) => handleWidgetAction("track_wallet", address)}
onViewTransaction={(txHash) => handleWidgetAction("view_transaction", txHash)}
/>
);
case "trading_suggestion":
return (
<TradingSuggestionWidget
suggestion={widget.suggestion}
onSetAlerts={() => handleWidgetAction("set_alerts", widget.suggestion)}
onViewChart={() => handleWidgetAction("view_chart", widget.suggestion)}
/>
);
case "portfolio":
return (
<PortfolioWidget
portfolio={widget.portfolio}
onRefresh={() => handleWidgetAction("refresh_portfolio")}
onAnalyzeToken={(holding) => handleWidgetAction("analyze_token", holding)}
onSetAlert={(holding) => handleWidgetAction("set_alert", holding)}
onViewToken={(holding) => handleWidgetAction("view_token", holding)}
onAddPosition={() => handleWidgetAction("add_position")}
/>
);
case "chart_capture":
return (
<ChartCaptureWidget
metadata={widget.metadata}
onCapture={() => handleWidgetAction("capture_chart")}
onExport={(format) => handleWidgetAction("export_chart", format)}
/>
);
case "thread_generator":
return (
<ThreadGeneratorWidget
tokenAddress={widget.tokenAddress}
tokenSymbol={widget.tokenSymbol}
chain={widget.chain}
onGenerate={(request) => handleWidgetAction("generate_thread", request)}
onExport={(format) => handleWidgetAction("export_thread", format)}
/>
);
default:
return null;
}
};
return (
<div className="p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex flex-col",
message.role === "user" ? "items-end" : "items-start"
)}
>
{/* Message bubble */}
<div
className={cn(
"max-w-[85%] rounded-lg p-3",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
message.isStreaming && "animate-pulse"
)}
>
<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,49 @@
import { Camera } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { useToast } from "@/routes/ui/use-toast";
/**
* Quick capture button (sticky at bottom)
* Reuses existing capture functionality
*/
export function QuickCapture() {
const { toast } = useToast();
const handleCapture = async () => {
try {
// Get active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) {
throw new Error("No active tab");
}
// Send message to background to capture page
chrome.runtime.sendMessage({
type: "CAPTURE_PAGE",
tabId: tab.id,
});
toast({
title: "Page captured!",
description: "Saved to your search space",
});
} catch (error) {
console.error("Failed to capture page:", error);
toast({
title: "Capture failed",
description: "Please try again",
variant: "destructive",
});
}
};
return (
<div className="border-t p-3 bg-background">
<Button className="w-full" variant="outline" onClick={handleCapture}>
<Camera className="mr-2 h-4 w-4" />
Save Current Page
</Button>
</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,5 @@
export { ChatHeader } from "./ChatHeader";
export { ChatMessages } from "./ChatMessages";
export { ChatInput } from "./ChatInput";
export { QuickCapture } from "./QuickCapture";
export { ChatInterface } from "./ChatInterface";

View file

@ -0,0 +1,84 @@
import { Coins, ExternalLink } from "lucide-react";
import { Button } from "@/routes/ui/button";
import { cn } from "~/lib/utils";
import type { TokenData } from "../context/PageContextProvider";
import { ChainIcon } from "./shared/ChainIcon";
export interface DetectedTokensListProps {
/** List of detected tokens */
tokens: TokenData[];
/** Callback when a token is clicked */
onTokenClick?: (token: TokenData) => void;
/** Additional class names */
className?: string;
}
/**
* DetectedTokensList - Display list of tokens detected from page content
*
* Features:
* - Shows tokens detected from Twitter mentions, contract addresses, trading pairs
* - Click to analyze token
* - Shows chain icon for each token
*/
export function DetectedTokensList({
tokens,
onTokenClick,
className,
}: DetectedTokensListProps) {
if (tokens.length === 0) {
return null;
}
return (
<div className={cn("border-b bg-muted/30", className)}>
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<Coins className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Detected Tokens</h3>
<span className="text-xs text-muted-foreground">
({tokens.length} found)
</span>
</div>
<div className="space-y-1">
{tokens.slice(0, 5).map((token, index) => (
<button
key={index}
onClick={() => onTokenClick?.(token)}
className="w-full flex items-center justify-between p-2 rounded-md hover:bg-background transition-colors text-left"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<ChainIcon chain={token.chain} size="sm" />
<div className="flex-1 min-w-0">
{token.tokenSymbol ? (
<div className="font-medium text-sm truncate">
{token.tokenSymbol}
</div>
) : (
<div className="text-xs text-muted-foreground font-mono truncate">
{token.pairAddress.slice(0, 8)}...{token.pairAddress.slice(-6)}
</div>
)}
{token.tokenName && (
<div className="text-xs text-muted-foreground truncate">
{token.tokenName}
</div>
)}
</div>
</div>
<ExternalLink className="h-3 w-3 text-muted-foreground flex-shrink-0" />
</button>
))}
</div>
{tokens.length > 5 && (
<div className="text-xs text-muted-foreground text-center mt-2">
+{tokens.length - 5} more tokens detected
</div>
)}
</div>
</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

@ -0,0 +1,382 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
MessageSquare,
Sparkles,
Copy,
Twitter,
Edit2,
Trash2,
Plus,
RefreshCw,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface Tweet {
number: number;
content: string;
type: "hook" | "analysis" | "implication" | "conclusion" | "disclaimer";
includeChart?: boolean;
}
export interface ThreadRequest {
tokenAddress: string;
tokenSymbol: string;
chain: string;
topic?: string;
length: number;
tone: "bullish" | "neutral" | "bearish";
}
export interface GeneratedThread {
tweets: Tweet[];
metadata: {
tokenSymbol: string;
keyStats: Record<string, any>;
};
}
export interface ThreadGeneratorPanelProps {
/** Current token info */
tokenAddress?: string;
tokenSymbol?: string;
chain?: string;
/** Callback when thread is generated */
onGenerate?: (request: ThreadRequest) => void;
/** Callback when thread is exported */
onExport?: (format: "copy" | "twitter") => void;
/** Additional class names */
className?: string;
}
/**
* ThreadGeneratorPanel - AI-powered Twitter thread generator
*
* Features:
* - Auto-fill token info from current page
* - Customizable thread length (5-10 tweets)
* - Tone selection (bullish/neutral/bearish)
* - AI-generated thread structure (Hook Analysis Implications Conclusion)
* - Edit individual tweets
* - Reorder tweets
* - Export options (copy all, tweet directly)
*/
export function ThreadGeneratorPanel({
tokenAddress,
tokenSymbol,
chain,
onGenerate,
onExport,
className,
}: ThreadGeneratorPanelProps) {
const [request, setRequest] = useState<ThreadRequest>({
tokenAddress: tokenAddress || "",
tokenSymbol: tokenSymbol || "",
chain: chain || "solana",
topic: "",
length: 7,
tone: "bullish",
});
const [generatedThread, setGeneratedThread] = useState<GeneratedThread | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [editingTweet, setEditingTweet] = useState<number | null>(null);
// Mock generated thread
const mockThread: GeneratedThread = {
tweets: [
{
number: 1,
content: `🧵 ${request.tokenSymbol} is showing massive volume spike (+200%) in the last 24h. Here's what you need to know 👇`,
type: "hook",
},
{
number: 2,
content: `Contract analysis:\n✅ Verified on-chain\n✅ Ownership renounced\n✅ LP locked for 90 days\n✅ No proxy contracts\n\nSolid fundamentals from a security perspective.`,
type: "analysis",
},
{
number: 3,
content: `Holder distribution looks healthy:\n• 1,234 holders\n• Top 10 hold only 35%\n• No single whale dominance\n\nThis suggests organic growth and reduced rug pull risk.`,
type: "analysis",
},
{
number: 4,
content: `Liquidity: $50K\nVolume/Liquidity ratio: 2.0x\n\nStrong trading activity relative to liquidity. This is a bullish signal for price discovery.`,
type: "analysis",
},
{
number: 5,
content: `Social sentiment is turning positive:\n• 500 Twitter mentions (24h)\n• 1,200 Telegram messages\n• Growing community engagement\n\nMomentum is building.`,
type: "implication",
},
{
number: 6,
content: `What this means:\n\nWe're seeing early signs of a potential breakout. Volume precedes price, and the fundamentals support sustained growth.`,
type: "implication",
},
{
number: 7,
content: `TL;DR:\n✅ Verified & safe contract\n✅ Healthy holder distribution\n✅ Strong volume growth\n✅ Positive social sentiment\n\nDYOR, but this one's worth watching closely. 👀`,
type: "conclusion",
},
],
metadata: {
tokenSymbol: request.tokenSymbol,
keyStats: {
price: 0.0001234,
change24h: 15.5,
volume: 100000,
liquidity: 50000,
},
},
};
const handleGenerate = async () => {
setIsGenerating(true);
await onGenerate?.(request);
// Mock: simulate generation
setTimeout(() => {
setGeneratedThread(mockThread);
setIsGenerating(false);
}, 2000);
};
const handleEditTweet = (number: number, newContent: string) => {
if (!generatedThread) return;
const updatedTweets = generatedThread.tweets.map((tweet) =>
tweet.number === number ? { ...tweet, content: newContent } : tweet
);
setGeneratedThread({ ...generatedThread, tweets: updatedTweets });
setEditingTweet(null);
};
const handleDeleteTweet = (number: number) => {
if (!generatedThread) return;
const updatedTweets = generatedThread.tweets
.filter((tweet) => tweet.number !== number)
.map((tweet, index) => ({ ...tweet, number: index + 1 }));
setGeneratedThread({ ...generatedThread, tweets: updatedTweets });
};
const handleAddTweet = () => {
if (!generatedThread) return;
const newTweet: Tweet = {
number: generatedThread.tweets.length + 1,
content: "New tweet content...",
type: "analysis",
};
setGeneratedThread({
...generatedThread,
tweets: [...generatedThread.tweets, newTweet],
});
};
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">
<MessageSquare className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">AI Thread Generator</h2>
<p className="text-xs text-muted-foreground">
Create Twitter threads with AI
</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{!generatedThread ? (
<>
{/* Input Form */}
<div className="space-y-3">
{/* Token Info */}
<div className="space-y-2">
<label className="text-sm font-medium">Token</label>
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
<span className="font-semibold">{request.tokenSymbol || "Not selected"}</span>
{request.chain && <ChainIcon chain={request.chain} size="xs" />}
</div>
</div>
{/* Topic */}
<div className="space-y-2">
<label className="text-sm font-medium">Topic (Optional)</label>
<input
type="text"
placeholder="Auto-generate from token data"
value={request.topic}
onChange={(e) => setRequest({ ...request, topic: e.target.value })}
className="w-full p-2 text-sm border rounded"
/>
</div>
{/* Length */}
<div className="space-y-2">
<label className="text-sm font-medium">Length</label>
<select
value={request.length}
onChange={(e) => setRequest({ ...request, length: parseInt(e.target.value) })}
className="w-full p-2 text-sm border rounded"
>
{[5, 6, 7, 8, 9, 10].map((len) => (
<option key={len} value={len}>
{len} tweets
</option>
))}
</select>
</div>
{/* Tone */}
<div className="space-y-2">
<label className="text-sm font-medium">Tone</label>
<div className="flex gap-2">
{(["bullish", "neutral", "bearish"] as const).map((tone) => (
<button
key={tone}
className={cn(
"flex-1 p-2 rounded border text-xs font-medium transition-colors",
request.tone === tone
? "bg-primary text-primary-foreground border-primary"
: "bg-muted hover:bg-muted/80"
)}
onClick={() => setRequest({ ...request, tone })}
>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</button>
))}
</div>
</div>
</div>
{/* Generate Button */}
<Button
variant="default"
className="w-full"
onClick={handleGenerate}
disabled={isGenerating || !request.tokenSymbol}
>
<Sparkles className="h-4 w-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Thread"}
</Button>
</>
) : (
<>
{/* Generated Thread Preview */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Preview</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setGeneratedThread(null)}
>
<RefreshCw className="h-3 w-3 mr-1" />
Regenerate
</Button>
</div>
{/* Tweets */}
<div className="space-y-2">
{generatedThread.tweets.map((tweet) => (
<div
key={tweet.number}
className="p-3 border rounded-lg bg-background"
>
{editingTweet === tweet.number ? (
<div className="space-y-2">
<textarea
value={tweet.content}
onChange={(e) =>
handleEditTweet(tweet.number, e.target.value)
}
className="w-full p-2 text-sm border rounded min-h-[80px]"
/>
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={() => setEditingTweet(null)}
>
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setEditingTweet(null)}
>
Cancel
</Button>
</div>
</div>
) : (
<>
<div className="flex items-start justify-between mb-2">
<span className="text-xs font-semibold text-muted-foreground">
{tweet.number}/{generatedThread.tweets.length}
</span>
<div className="flex gap-1">
<button
className="p-1 hover:bg-muted rounded"
onClick={() => setEditingTweet(tweet.number)}
>
<Edit2 className="h-3 w-3" />
</button>
<button
className="p-1 hover:bg-muted rounded"
onClick={() => handleDeleteTweet(tweet.number)}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap">{tweet.content}</p>
</>
)}
</div>
))}
</div>
{/* Add Tweet Button */}
<Button
variant="outline"
className="w-full"
onClick={handleAddTweet}
>
<Plus className="h-4 w-4 mr-2" />
Add Tweet
</Button>
</div>
</>
)}
</div>
{/* Footer - Export Options */}
{generatedThread && (
<div className="border-t p-3 space-y-2">
<Button
variant="default"
className="w-full"
onClick={() => onExport?.("copy")}
>
<Copy className="h-4 w-4 mr-2" />
Copy All Tweets
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => onExport?.("twitter")}
>
<Twitter className="h-4 w-4 mr-2" />
Tweet Now
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { MOCK_TOKEN_DATA, MOCK_MODE } from "../mock/mockData";
/**
* Page context types
*/
export type PageType = "dexscreener" | "coingecko" | "twitter" | "generic";
export interface TokenData {
chain: string;
pairAddress: string;
tokenSymbol?: string;
tokenName?: string;
price?: string;
priceChange24h?: number;
volume24h?: string;
liquidity?: string;
marketCap?: string;
}
export interface PageContext {
url: string;
title: string;
pageType: PageType;
tokenData?: TokenData;
/** Detected tokens from page content (Twitter mentions, addresses, pairs) */
detectedTokens?: TokenData[];
}
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() {
return useContext(PageContextContext);
}
/**
* 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") {
setContext(message.data);
}
};
chrome.runtime.onMessage.addListener(handleMessage);
// Request initial context
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.tabs.sendMessage(tabs[0].id, { type: "GET_PAGE_CONTEXT" });
}
});
return () => {
chrome.runtime.onMessage.removeListener(handleMessage);
};
}, []);
return (
<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

@ -0,0 +1,223 @@
import { useState } from "react";
import type { TokenData } from "../context/PageContextProvider";
import { Button } from "@/routes/ui/button";
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: 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;
}
/**
* 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,
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">
{/* 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 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>
{/* 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 truncate">{tokenData.volume24h || "—"}</p>
</div>
<div>
<span className="text-muted-foreground block text-xs">Liquidity</span>
<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 - 4 buttons in grid, responsive */}
<div className="grid grid-cols-4 gap-1 mt-3">
<Button
size="sm"
variant="outline"
className="h-8 px-1.5 text-xs flex items-center justify-center"
onClick={() => handleQuickAction("safety")}
>
<Shield className="h-3 w-3 mr-0.5 flex-shrink-0" />
<span className="truncate">Safety</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-1.5 text-xs flex items-center justify-center"
onClick={() => handleQuickAction("holders")}
>
<Users className="h-3 w-3 mr-0.5 flex-shrink-0" />
<span className="truncate">Holders</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-1.5 text-xs flex items-center justify-center"
onClick={() => handleQuickAction("predict")}
>
<TrendingUp className="h-3 w-3 mr-0.5 flex-shrink-0" />
<span className="truncate">Predict</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-1.5 text-xs flex items-center justify-center 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="h-3 w-3 mr-0.5 flex-shrink-0" />
<span className="truncate">Rug</span>
</Button>
</div>
</div>
);
}

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,24 @@
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "@/routes/ui/toaster";
import { ChatInterface } from "./chat/ChatInterface";
import { PageContextProvider } from "./context/PageContextProvider";
/**
* Main Side Panel Application
* Provides AI chat interface with page context awareness
*/
export function SidePanelApp() {
return (
<PageContextProvider>
<MemoryRouter>
<div className="flex flex-col h-full">
{/* Main chat interface */}
<ChatInterface />
{/* Toast notifications */}
<Toaster />
</div>
</MemoryRouter>
</PageContextProvider>
);
}

View file

@ -0,0 +1,631 @@
/**
* 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";
import type { WhaleTransaction } from "../whale/WhaleActivityFeed";
import type { TokenAnalysisData } from "../analysis/TokenAnalysisPanel";
import type { TradingSuggestion } from "../analysis/TradingSuggestionPanel";
import type { PortfolioData } from "../portfolio/PortfolioPanel";
// ============================================
// 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 WHALE TRANSACTIONS
// ============================================
export const MOCK_WHALE_TRANSACTIONS: WhaleTransaction[] = [
{
id: "whale-1",
tokenSymbol: "BULLA",
tokenName: "Bulla Token",
chain: "solana",
type: "buy",
amountUSD: 100000,
amountTokens: "8.1B",
walletAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
txHash: "5Kn8WqXZKqYqKqYqKqYqKqYqKqYqKqYqKqYqKqYqKqYq",
timestamp: new Date(Date.now() - 1000 * 60 * 2), // 2 mins ago
isSmartMoney: true,
isInWatchlist: true,
},
{
id: "whale-2",
tokenSymbol: "BONK",
tokenName: "Bonk",
chain: "solana",
type: "sell",
amountUSD: 50000,
amountTokens: "2.3B",
walletAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
txHash: "3Hn7WpXZKpYpKpYpKpYpKpYpKpYpKpYpKpYpKpYpKpYp",
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 mins ago
isSmartMoney: false,
isInWatchlist: true,
},
{
id: "whale-3",
tokenSymbol: "PEPE",
tokenName: "Pepe",
chain: "ethereum",
type: "buy",
amountUSD: 250000,
amountTokens: "23B",
walletAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
txHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 mins ago
isSmartMoney: true,
isInWatchlist: true,
},
{
id: "whale-4",
tokenSymbol: "WIF",
tokenName: "dogwifhat",
chain: "solana",
type: "buy",
amountUSD: 75000,
amountTokens: "30.6K",
walletAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
txHash: "4Jm6VoXYJoXoJoXoJoXoJoXoJoXoJoXoJoXoJoXoJoXo",
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 mins ago
isSmartMoney: false,
isInWatchlist: true,
},
{
id: "whale-5",
tokenSymbol: "DEGEN",
tokenName: "Degen",
chain: "base",
type: "sell",
amountUSD: 35000,
amountTokens: "2.2M",
walletAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
txHash: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 mins ago
isSmartMoney: false,
isInWatchlist: true,
},
{
id: "whale-6",
tokenSymbol: "SOL",
tokenName: "Solana",
chain: "solana",
type: "buy",
amountUSD: 500000,
amountTokens: "5K",
walletAddress: "So11111111111111111111111111111111111111112",
txHash: "6Lp7XqYZLqZqLqZqLqZqLqZqLqZqLqZqLqZqLqZqLqZq",
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 mins ago
isSmartMoney: true,
isInWatchlist: false,
},
{
id: "whale-7",
tokenSymbol: "MATIC",
tokenName: "Polygon",
chain: "ethereum",
type: "buy",
amountUSD: 150000,
amountTokens: "200K",
walletAddress: "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",
txHash: "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
timestamp: new Date(Date.now() - 1000 * 60 * 45), // 45 mins ago
isSmartMoney: false,
isInWatchlist: false,
},
];
// ============================================
// MOCK TOKEN ANALYSIS
// ============================================
export const MOCK_TOKEN_ANALYSIS: TokenAnalysisData = {
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
tokenSymbol: "BULLA",
tokenName: "Bulla Token",
chain: "solana",
timestamp: new Date(),
contract: {
verified: true,
renounced: true,
isProxy: false,
sourceCode: true,
},
holders: {
count: 1234,
top10Percent: 35,
distribution: [
{ address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", percent: 8.5 },
{ address: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", percent: 6.2 },
{ address: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", percent: 5.8 },
],
},
liquidity: {
totalUSD: 50000,
lpLocked: true,
lpLockDuration: 90,
liquidityMcapRatio: 0.15,
},
volume: {
volume24h: 100000,
trend: "increasing",
volumeLiquidityRatio: 2.0,
},
price: {
current: 0.0001234,
ath: 0.0005,
atl: 0.00001,
change7d: 15.5,
change30d: 45.2,
volatility: 12.5,
},
social: {
twitterMentions: 500,
telegramActivity: 1200,
redditDiscussions: 45,
sentimentScore: 0.75,
sentiment: "positive",
},
aiSummary: "BULLA shows strong holder distribution with verified contract and renounced ownership. Volume increasing 200% in 24h with locked liquidity for 90 days. Social sentiment is highly positive with growing community engagement. Moderate risk profile with good upside potential.",
recommendation: "buy",
confidence: 75,
};
// ============================================
// MOCK TRADING SUGGESTION
// ============================================
export const MOCK_TRADING_SUGGESTION: TradingSuggestion = {
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
tokenSymbol: "BULLA",
tokenName: "Bulla Token",
chain: "solana",
currentPrice: 0.0001234,
timestamp: new Date(),
entry: {
min: 0.0001100,
max: 0.0001250,
reasoning: "Strong support zone at 0.00011 with high volume. Current price offers good risk/reward entry.",
},
targets: [
{
level: 1,
price: 0.0001800,
percentGain: 45.8,
confidence: 85,
},
{
level: 2,
price: 0.0002500,
percentGain: 102.6,
confidence: 70,
},
{
level: 3,
price: 0.0003500,
percentGain: 183.7,
confidence: 50,
},
],
stopLoss: {
price: 0.0000950,
percentLoss: -23.0,
reasoning: "Below key support level. Invalidates bullish structure if broken.",
},
riskReward: 3.2,
overallConfidence: 78,
technicalLevels: {
support: [0.0001100, 0.0000950, 0.0000800],
resistance: [0.0001800, 0.0002500, 0.0003500],
},
reasoning: [
"Strong accumulation pattern forming on 4H chart",
"Volume profile shows increasing buyer interest",
"RSI showing bullish divergence at support",
"Whale wallets accumulating over past 48 hours",
"Social sentiment turning positive with growing community",
],
invalidationConditions: [
"Break below 0.000095 with high volume",
"Sudden large holder dumping (>5% supply)",
"Liquidity removal or unlock event",
"Negative news or security concerns",
],
};
// ============================================
// MOCK PORTFOLIO DATA
// ============================================
export const MOCK_PORTFOLIO: PortfolioData = {
wallets: [
{
address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
chain: "solana",
type: "phantom",
},
{
address: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
chain: "ethereum",
type: "metamask",
},
],
totalValue: 12450.50,
change24h: 850.25,
change24hPercent: 7.33,
holdings: [
{
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
chain: "solana",
symbol: "BULLA",
name: "Bulla Token",
amount: "8,100,000",
currentPrice: 0.0001234,
currentValue: 1000.00,
change24h: 150.00,
change24hPercent: 17.65,
entryPrice: 0.0001000,
pnl: 189.54,
pnlPercent: 23.4,
},
{
tokenAddress: "So11111111111111111111111111111111111111112",
chain: "solana",
symbol: "SOL",
name: "Solana",
amount: "50",
currentPrice: 100.50,
currentValue: 5025.00,
change24h: 250.00,
change24hPercent: 5.24,
entryPrice: 95.00,
pnl: 275.00,
pnlPercent: 5.79,
},
{
tokenAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
chain: "solana",
symbol: "BONK",
name: "Bonk",
amount: "2,300,000,000",
currentPrice: 0.00001500,
currentValue: 3450.00,
change24h: -125.00,
change24hPercent: -3.50,
entryPrice: 0.00001200,
pnl: 690.00,
pnlPercent: 25.0,
},
{
tokenAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
chain: "ethereum",
symbol: "PEPE",
name: "Pepe",
amount: "23,000,000,000",
currentPrice: 0.00000012,
currentValue: 2760.00,
change24h: 180.00,
change24hPercent: 6.98,
entryPrice: 0.00000010,
pnl: 460.00,
pnlPercent: 20.0,
},
{
tokenAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
chain: "solana",
symbol: "WIF",
name: "dogwifhat",
amount: "100",
currentPrice: 2.15,
currentValue: 215.50,
change24h: -15.50,
change24hPercent: -6.71,
entryPrice: 2.50,
pnl: -35.00,
pnlPercent: -14.0,
},
],
analytics: {
bestPerformer: {
symbol: "BONK",
change: 25.0,
},
worstPerformer: {
symbol: "WIF",
change: -14.0,
},
winRate: 80,
avgHoldTime: 14,
totalTrades: 25,
},
};
// ============================================
// 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,287 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
TrendingUp,
TrendingDown,
Wallet,
Plus,
BarChart3,
ExternalLink,
RefreshCw,
Star,
Bell,
Eye,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface PortfolioHolding {
tokenAddress: string;
chain: string;
symbol: string;
name: string;
amount: string;
currentPrice: number;
currentValue: number;
change24h: number;
change24hPercent: number;
entryPrice?: number;
pnl?: number;
pnlPercent?: number;
}
export interface PortfolioAnalytics {
bestPerformer: { symbol: string; change: number };
worstPerformer: { symbol: string; change: number };
winRate: number;
avgHoldTime: number;
totalTrades: number;
}
export interface PortfolioData {
wallets: {
address: string;
chain: string;
type: "metamask" | "phantom" | "coinbase";
}[];
totalValue: number;
change24h: number;
change24hPercent: number;
holdings: PortfolioHolding[];
analytics: PortfolioAnalytics;
}
export interface PortfolioPanelProps {
/** Portfolio data */
portfolio: PortfolioData;
/** Callback when refresh is clicked */
onRefresh?: () => void;
/** Callback when "Analyze" is clicked for a token */
onAnalyzeToken?: (holding: PortfolioHolding) => void;
/** Callback when "Set Alert" is clicked for a token */
onSetAlert?: (holding: PortfolioHolding) => void;
/** Callback when "View on DexScreener" is clicked */
onViewToken?: (holding: PortfolioHolding) => void;
/** Callback when "Add Manual Position" is clicked */
onAddPosition?: () => void;
/** Whether data is loading */
isLoading?: boolean;
/** Additional class names */
className?: string;
}
/**
* PortfolioPanel - Portfolio tracker with holdings and P&L
*
* Features:
* - Total portfolio value and 24h change
* - List of holdings with current value and P&L
* - Performance analytics (best/worst performers, win rate)
* - Quick actions per token (analyze, alert, view)
* - Manual position entry
*/
export function PortfolioPanel({
portfolio,
onRefresh,
onAnalyzeToken,
onSetAlert,
onViewToken,
onAddPosition,
isLoading = false,
className,
}: PortfolioPanelProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await onRefresh?.();
setTimeout(() => setIsRefreshing(false), 1000);
};
const formatCurrency = (value: number) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
return `$${value.toFixed(2)}`;
};
const formatPercent = (value: number) => {
const sign = value >= 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
};
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">
<Wallet className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">Portfolio</h2>
<p className="text-xs text-muted-foreground">
{portfolio.holdings.length} tokens
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Total Value */}
<div className="p-4 border-b bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Total Value</div>
<div className="flex items-baseline gap-2 mb-2">
<span className="font-bold text-3xl">{formatCurrency(portfolio.totalValue)}</span>
</div>
<div className="flex items-center gap-2">
<span className={cn(
"font-semibold text-sm",
portfolio.change24hPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
)}>
{formatPercent(portfolio.change24hPercent)}
</span>
<span className="text-xs text-muted-foreground">
({portfolio.change24h >= 0 ? "+" : ""}{formatCurrency(portfolio.change24h)}) 24h
</span>
{portfolio.change24hPercent >= 0 ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
</div>
{/* Holdings List */}
<div className="divide-y">
{portfolio.holdings.map((holding) => (
<div key={`${holding.chain}-${holding.tokenAddress}`} className="p-4 hover:bg-muted/50 transition-colors">
{/* Token Info */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{holding.symbol}</span>
<ChainIcon chain={holding.chain} size="xs" />
</div>
<div className="text-xs text-muted-foreground">{holding.name}</div>
</div>
</div>
<div className="text-right">
<div className="font-semibold">{formatCurrency(holding.currentValue)}</div>
<div className={cn(
"text-xs font-medium",
holding.change24hPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
)}>
{formatPercent(holding.change24hPercent)}
</div>
</div>
</div>
{/* Amount and Price */}
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
<span>{holding.amount} tokens</span>
<span>${holding.currentPrice.toFixed(6)}</span>
</div>
{/* P&L (if available) */}
{holding.pnl !== undefined && holding.pnlPercent !== undefined && (
<div className="flex items-center justify-between mb-3 p-2 bg-muted/50 rounded">
<span className="text-xs text-muted-foreground">P&L</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-semibold",
holding.pnl >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
)}>
{holding.pnl >= 0 ? "+" : ""}{formatCurrency(holding.pnl)}
</span>
<span className={cn(
"text-xs font-medium",
holding.pnlPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
)}>
({formatPercent(holding.pnlPercent)})
</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onAnalyzeToken?.(holding)}
>
<BarChart3 className="h-3 w-3 mr-1" />
Analyze
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onSetAlert?.(holding)}
>
<Bell className="h-3 w-3 mr-1" />
Alert
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onViewToken?.(holding)}
>
<Eye className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
{/* Add Position Button */}
<div className="p-4 border-t">
<Button
variant="outline"
className="w-full"
onClick={onAddPosition}
>
<Plus className="h-4 w-4 mr-2" />
Add Manual Position
</Button>
</div>
{/* Performance Analytics */}
<div className="p-4 border-t bg-muted/30">
<h3 className="font-semibold text-sm mb-3">Performance</h3>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 bg-background rounded">
<span className="text-xs text-muted-foreground">Best Performer</span>
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
{portfolio.analytics.bestPerformer.symbol} (+{portfolio.analytics.bestPerformer.change.toFixed(1)}%)
</span>
</div>
<div className="flex items-center justify-between p-2 bg-background rounded">
<span className="text-xs text-muted-foreground">Worst Performer</span>
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
{portfolio.analytics.worstPerformer.symbol} ({portfolio.analytics.worstPerformer.change.toFixed(1)}%)
</span>
</div>
<div className="flex items-center justify-between p-2 bg-background rounded">
<span className="text-xs text-muted-foreground">Win Rate</span>
<span className="text-xs font-semibold">{portfolio.analytics.winRate}%</span>
</div>
</div>
</div>
</div>
</div>
);
}

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,374 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
Settings,
Bell,
BellOff,
Clock,
Keyboard,
Menu,
Save,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
export interface NotificationSettings {
enabled: boolean;
priorities: {
high: boolean;
medium: boolean;
low: boolean;
};
quietHours: {
enabled: boolean;
start: string;
end: string;
};
groupNotifications: boolean;
smartBatching: boolean;
}
export interface KeyboardShortcut {
id: string;
action: string;
shortcut: string;
description: string;
}
export interface QuickActionsSettings {
contextMenuEnabled: boolean;
autoDetectAddresses: boolean;
}
export interface ProductivitySettingsData {
notifications: NotificationSettings;
shortcuts: KeyboardShortcut[];
quickActions: QuickActionsSettings;
}
export interface ProductivitySettingsPanelProps {
/** Current settings */
settings?: ProductivitySettingsData;
/** Callback when settings are saved */
onSave?: (settings: ProductivitySettingsData) => void;
/** Additional class names */
className?: string;
}
/**
* ProductivitySettingsPanel - Productivity settings management
*
* Features:
* - Notification settings (priorities, quiet hours, grouping)
* - Keyboard shortcuts configuration
* - Quick actions settings (context menu, auto-detect)
* - Per-token notification settings
*/
export function ProductivitySettingsPanel({
settings: initialSettings,
onSave,
className,
}: ProductivitySettingsPanelProps) {
const [settings, setSettings] = useState<ProductivitySettingsData>(
initialSettings || {
notifications: {
enabled: true,
priorities: {
high: true,
medium: true,
low: false,
},
quietHours: {
enabled: true,
start: "23:00",
end: "07:00",
},
groupNotifications: true,
smartBatching: true,
},
shortcuts: [
{ id: "open-panel", action: "Open Side Panel", shortcut: "Cmd+Shift+S", description: "Open/close the side panel" },
{ id: "new-chat", action: "New Chat", shortcut: "Cmd+Shift+N", description: "Start a new chat" },
{ id: "analyze-token", action: "Analyze Token", shortcut: "Cmd+Shift+A", description: "Analyze current token" },
{ id: "add-watchlist", action: "Add to Watchlist", shortcut: "Cmd+Shift+W", description: "Add token to watchlist" },
{ id: "capture-chart", action: "Capture Chart", shortcut: "Cmd+Shift+C", description: "Capture chart screenshot" },
{ id: "open-portfolio", action: "Open Portfolio", shortcut: "Cmd+Shift+P", description: "Open portfolio panel" },
],
quickActions: {
contextMenuEnabled: true,
autoDetectAddresses: true,
},
}
);
const [hasChanges, setHasChanges] = useState(false);
const updateSettings = (updates: Partial<ProductivitySettingsData>) => {
setSettings({ ...settings, ...updates });
setHasChanges(true);
};
const handleSave = () => {
onSave?.(settings);
setHasChanges(false);
};
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">
<Settings className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold">Productivity Settings</h2>
<p className="text-xs text-muted-foreground">
Notifications, shortcuts, and quick actions
</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Notifications */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
{/* Enable/Disable */}
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">Enable notifications</span>
<input
type="checkbox"
checked={settings.notifications.enabled}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
enabled: e.target.checked,
},
})
}
className="rounded"
/>
</label>
{/* Priority Levels */}
<div className="space-y-2">
<label className="text-xs font-semibold text-muted-foreground">Priority Levels</label>
<div className="space-y-2 pl-4">
{(["high", "medium", "low"] as const).map((priority) => (
<label key={priority} className="flex items-center justify-between cursor-pointer">
<span className="text-sm capitalize">{priority}</span>
<input
type="checkbox"
checked={settings.notifications.priorities[priority]}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
priorities: {
...settings.notifications.priorities,
[priority]: e.target.checked,
},
},
})
}
className="rounded"
disabled={!settings.notifications.enabled}
/>
</label>
))}
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<label className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">Quiet Hours</span>
</div>
<input
type="checkbox"
checked={settings.notifications.quietHours.enabled}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
quietHours: {
...settings.notifications.quietHours,
enabled: e.target.checked,
},
},
})
}
className="rounded"
disabled={!settings.notifications.enabled}
/>
</label>
{settings.notifications.quietHours.enabled && (
<div className="flex gap-2 pl-6">
<input
type="time"
value={settings.notifications.quietHours.start}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
quietHours: {
...settings.notifications.quietHours,
start: e.target.value,
},
},
})
}
className="flex-1 p-1 text-xs border rounded"
/>
<span className="text-xs text-muted-foreground">to</span>
<input
type="time"
value={settings.notifications.quietHours.end}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
quietHours: {
...settings.notifications.quietHours,
end: e.target.value,
},
},
})
}
className="flex-1 p-1 text-xs border rounded"
/>
</div>
)}
</div>
{/* Grouping Options */}
<div className="space-y-2">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">Group notifications</span>
<input
type="checkbox"
checked={settings.notifications.groupNotifications}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
groupNotifications: e.target.checked,
},
})
}
className="rounded"
disabled={!settings.notifications.enabled}
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">Smart batching (5+ alerts)</span>
<input
type="checkbox"
checked={settings.notifications.smartBatching}
onChange={(e) =>
updateSettings({
notifications: {
...settings.notifications,
smartBatching: e.target.checked,
},
})
}
className="rounded"
disabled={!settings.notifications.enabled}
/>
</label>
</div>
</div>
{/* Keyboard Shortcuts */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Keyboard Shortcuts</h3>
</div>
<div className="space-y-2">
{settings.shortcuts.map((shortcut) => (
<div
key={shortcut.id}
className="flex items-center justify-between p-2 bg-muted/50 rounded"
>
<div>
<div className="text-sm font-medium">{shortcut.action}</div>
<div className="text-xs text-muted-foreground">{shortcut.description}</div>
</div>
<kbd className="px-2 py-1 text-xs font-mono bg-background border rounded">
{shortcut.shortcut}
</kbd>
</div>
))}
</div>
<Button variant="outline" size="sm" className="w-full">
Customize Shortcuts
</Button>
</div>
{/* Quick Actions */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Menu className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold text-sm">Quick Actions</h3>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">Context menu enabled</span>
<input
type="checkbox"
checked={settings.quickActions.contextMenuEnabled}
onChange={(e) =>
updateSettings({
quickActions: {
...settings.quickActions,
contextMenuEnabled: e.target.checked,
},
})
}
className="rounded"
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">Auto-detect token addresses</span>
<input
type="checkbox"
checked={settings.quickActions.autoDetectAddresses}
onChange={(e) =>
updateSettings({
quickActions: {
...settings.quickActions,
autoDetectAddresses: e.target.checked,
},
})
}
className="rounded"
/>
</label>
</div>
</div>
</div>
{/* Footer - Save Button */}
<div className="border-t p-3">
<Button
variant="default"
className="w-full"
onClick={handleSave}
disabled={!hasChanges}
>
<Save className="h-4 w-4 mr-2" />
{hasChanges ? "Save Changes" : "No Changes"}
</Button>
</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,267 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import {
TrendingUp,
TrendingDown,
ExternalLink,
Filter,
Star,
Clock,
DollarSign,
Wallet,
} from "lucide-react";
import { Button } from "@/routes/ui/button";
import { ChainIcon } from "../components/shared/ChainIcon";
export interface WhaleTransaction {
/** Transaction ID */
id: string;
/** Token symbol */
tokenSymbol: string;
/** Token name */
tokenName?: string;
/** Blockchain chain */
chain: string;
/** Transaction type */
type: "buy" | "sell";
/** Amount in USD */
amountUSD: number;
/** Amount in tokens */
amountTokens: string;
/** Wallet address */
walletAddress: string;
/** Transaction hash */
txHash: string;
/** When the transaction occurred */
timestamp: Date;
/** Whether this is a smart money wallet */
isSmartMoney?: boolean;
/** Whether this token is in user's watchlist */
isInWatchlist?: boolean;
}
export interface WhaleActivityFeedProps {
/** List of whale transactions */
transactions: WhaleTransaction[];
/** Callback when transaction is clicked */
onTransactionClick?: (tx: WhaleTransaction) => void;
/** Callback when "Track Wallet" is clicked */
onTrackWallet?: (walletAddress: string) => void;
/** Callback when "View on Explorer" is clicked */
onViewExplorer?: (txHash: string, chain: string) => void;
/** Additional class names */
className?: string;
}
/**
* WhaleActivityFeed - Display whale transactions (large buys/sells >$10K)
*
* Features:
* - Real-time feed of large transactions
* - Filter by watchlist tokens or all tokens
* - Smart money wallet indicators
* - Quick links to block explorer
* - Track wallet functionality
*/
export function WhaleActivityFeed({
transactions,
onTransactionClick,
onTrackWallet,
onViewExplorer,
className,
}: WhaleActivityFeedProps) {
const [filter, setFilter] = useState<"all" | "watchlist" | "smart_money">("all");
// Filter transactions based on selected filter
const filteredTransactions = transactions.filter((tx) => {
if (filter === "watchlist") return tx.isInWatchlist;
if (filter === "smart_money") return tx.isSmartMoney;
return true;
});
const formatTimeAgo = (date: Date) => {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const formatAmount = (amount: number) => {
if (amount >= 1000000) return `$${(amount / 1000000).toFixed(2)}M`;
if (amount >= 1000) return `$${(amount / 1000).toFixed(1)}K`;
return `$${amount.toFixed(0)}`;
};
const shortenAddress = (address: string) => {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
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">
<div className="text-2xl">🐋</div>
<div>
<h2 className="font-semibold">Whale Activity</h2>
<p className="text-xs text-muted-foreground">
{filteredTransactions.length} transactions
</p>
</div>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-1 p-2 border-b bg-muted/30">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
onClick={() => setFilter("all")}
className="flex-1"
>
All
</Button>
<Button
variant={filter === "watchlist" ? "default" : "ghost"}
size="sm"
onClick={() => setFilter("watchlist")}
className="flex-1"
>
<Star className="h-3 w-3 mr-1" />
Watchlist
</Button>
<Button
variant={filter === "smart_money" ? "default" : "ghost"}
size="sm"
onClick={() => setFilter("smart_money")}
className="flex-1"
>
Smart Money
</Button>
</div>
{/* Transaction feed */}
<div className="flex-1 overflow-y-auto">
{filteredTransactions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<div className="text-4xl mb-2">🐋</div>
<p className="text-sm text-muted-foreground">
No whale activity detected yet
</p>
<p className="text-xs text-muted-foreground mt-1">
Large transactions (&gt;$10K) will appear here
</p>
</div>
) : (
<div className="divide-y">
{filteredTransactions.map((tx) => (
<div
key={tx.id}
className="p-4 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onTransactionClick?.(tx)}
>
{/* Time and smart money badge */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatTimeAgo(tx.timestamp)}
</div>
{tx.isSmartMoney && (
<div className="flex items-center gap-1 px-2 py-0.5 bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded-full text-xs font-medium">
Smart Money
</div>
)}
</div>
{/* Transaction type and amount */}
<div className="flex items-center gap-2 mb-2">
{tx.type === "buy" ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-semibold">
<TrendingUp className="h-4 w-4" />
BUY
</div>
) : (
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-semibold">
<TrendingDown className="h-4 w-4" />
SELL
</div>
)}
<span className="font-bold text-lg">
{formatAmount(tx.amountUSD)}
</span>
<span className="text-sm font-medium">
{tx.tokenSymbol}
</span>
<ChainIcon chain={tx.chain} size="sm" />
</div>
{/* Token amount */}
<div className="text-xs text-muted-foreground mb-2">
Amount: {tx.amountTokens} tokens
</div>
{/* Wallet address */}
<div className="flex items-center gap-2 mb-3">
<Wallet className="h-3 w-3 text-muted-foreground" />
<code className="text-xs bg-muted px-2 py-0.5 rounded">
{shortenAddress(tx.walletAddress)}
</code>
<button
className="text-xs text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(tx.walletAddress);
}}
>
Copy
</button>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewExplorer?.(tx.txHash, tx.chain);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
Explorer
</Button>
{!tx.isSmartMoney && (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={(e) => {
e.stopPropagation();
onTrackWallet?.(tx.walletAddress);
}}
>
<Star className="h-3 w-3 mr-1" />
Track Wallet
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer info */}
<div className="border-t p-3 bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Monitoring transactions &gt;$10K</span>
<span>Updates every 1 min</span>
</div>
</div>
</div>
);
}

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,31 @@
import { ChartCapturePanel, type ChartCaptureMetadata } from "../capture/ChartCapturePanel";
export interface ChartCaptureWidgetProps {
/** Current token metadata */
metadata?: ChartCaptureMetadata;
/** Callback when capture is clicked */
onCapture?: () => void;
/** Callback when export is clicked */
onExport?: (format: "twitter" | "telegram" | "instagram" | "clipboard") => void;
}
/**
* ChartCaptureWidget - Inline chart capture tool in chat
* Wraps ChartCapturePanel for conversational UX
*/
export function ChartCaptureWidget({
metadata,
onCapture,
onExport,
}: ChartCaptureWidgetProps) {
return (
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
<ChartCapturePanel
metadata={metadata}
onCapture={onCapture}
onExport={onExport}
/>
</div>
);
}

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,43 @@
import { PortfolioPanel, type PortfolioData, type PortfolioHolding } from "../portfolio/PortfolioPanel";
export interface PortfolioWidgetProps {
/** Portfolio data */
portfolio: PortfolioData;
/** Callback when refresh is clicked */
onRefresh?: () => void;
/** Callback when "Analyze" is clicked for a token */
onAnalyzeToken?: (holding: PortfolioHolding) => void;
/** Callback when "Set Alert" is clicked for a token */
onSetAlert?: (holding: PortfolioHolding) => void;
/** Callback when "View on DexScreener" is clicked */
onViewToken?: (holding: PortfolioHolding) => void;
/** Callback when "Add Manual Position" is clicked */
onAddPosition?: () => void;
}
/**
* PortfolioWidget - Inline portfolio display in chat
* Wraps PortfolioPanel for conversational UX
*/
export function PortfolioWidget({
portfolio,
onRefresh,
onAnalyzeToken,
onSetAlert,
onViewToken,
onAddPosition,
}: PortfolioWidgetProps) {
return (
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
<PortfolioPanel
portfolio={portfolio}
onRefresh={onRefresh}
onAnalyzeToken={onAnalyzeToken}
onSetAlert={onSetAlert}
onViewToken={onViewToken}
onAddPosition={onAddPosition}
/>
</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,40 @@
import { ThreadGeneratorPanel, type GeneratedThread } from "../content/ThreadGeneratorPanel";
export interface ThreadGeneratorWidgetProps {
/** Current token info */
tokenAddress?: string;
tokenSymbol?: string;
chain?: string;
/** Generated thread (if available) */
generatedThread?: GeneratedThread;
/** Callback when thread is generated */
onGenerate?: (request: any) => void;
/** Callback when thread is exported */
onExport?: (format: "copy" | "twitter") => void;
}
/**
* ThreadGeneratorWidget - Inline thread generator in chat
* Wraps ThreadGeneratorPanel for conversational UX
*/
export function ThreadGeneratorWidget({
tokenAddress,
tokenSymbol,
chain,
generatedThread,
onGenerate,
onExport,
}: ThreadGeneratorWidgetProps) {
return (
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
<ThreadGeneratorPanel
tokenAddress={tokenAddress}
tokenSymbol={tokenSymbol}
chain={chain}
onGenerate={onGenerate}
onExport={onExport}
/>
</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-1.5 flex-wrap">
<Button size="sm" variant="outline" onClick={onAddToWatchlist} className="flex-1 min-w-0 text-xs h-8">
<Star className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}</span>
</Button>
<Button size="sm" variant="outline" onClick={onSetAlert} className="h-8 w-8 p-0 flex-shrink-0">
<Bell className="h-3 w-3" />
</Button>
<Button size="sm" variant="default" onClick={onAnalyzeFurther} className="text-xs h-8">
Analyze More
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { TradingSuggestionPanel, type TradingSuggestion } from "../analysis/TradingSuggestionPanel";
export interface TradingSuggestionWidgetProps {
/** Trading suggestion data */
suggestion: TradingSuggestion;
/** Callback when "Set Alerts" is clicked */
onSetAlerts?: () => void;
/** Callback when "View Chart" is clicked */
onViewChart?: () => void;
}
/**
* TradingSuggestionWidget - Inline trading suggestion display in chat
* Wraps TradingSuggestionPanel for conversational UX
*/
export function TradingSuggestionWidget({
suggestion,
onSetAlerts,
onViewChart,
}: TradingSuggestionWidgetProps) {
return (
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
<TradingSuggestionPanel
suggestion={suggestion}
onSetAlerts={onSetAlerts}
onViewChart={onViewChart}
/>
</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

@ -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,31 @@
import { WhaleActivityFeed, type WhaleTransaction } from "../whale/WhaleActivityFeed";
export interface WhaleActivityWidgetProps {
/** List of whale transactions */
transactions: WhaleTransaction[];
/** Callback when a wallet is tracked */
onTrackWallet?: (address: string) => void;
/** Callback when a transaction is viewed */
onViewTransaction?: (txHash: string) => void;
}
/**
* WhaleActivityWidget - Inline whale activity display in chat
* Wraps WhaleActivityFeed for conversational UX
*/
export function WhaleActivityWidget({
transactions,
onTrackWallet,
onViewTransaction,
}: WhaleActivityWidgetProps) {
return (
<div className="my-3">
<WhaleActivityFeed
transactions={transactions}
onTrackWallet={onTrackWallet}
onViewTransaction={onViewTransaction}
/>
</div>
);
}

View file

@ -0,0 +1,27 @@
// 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";
// 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";

View file

@ -3,7 +3,13 @@ const { fontFamily } = require("tailwindcss/defaultTheme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./*.{js,jsx,ts,tsx}", "./routes/*.tsx", "./routes/**/*.tsx"],
content: [
"./*.{js,jsx,ts,tsx}",
"./routes/**/*.{js,jsx,ts,tsx}",
"./sidepanel/**/*.{js,jsx,ts,tsx}",
"./lib/**/*.{js,jsx,ts,tsx}",
"./background/**/*.{js,jsx,ts,tsx}",
],
theme: {
container: {
center: true,

View file

@ -1,12 +1,24 @@
{
"extends": "plasmo/templates/tsconfig.base.json",
"exclude": ["node_modules"],
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
"compilerOptions": {
"paths": {
"~*": ["./*"],
"@/*": ["./*"]
},
"baseUrl": "."
}
"extends": "plasmo/templates/tsconfig.base.json",
"exclude": [
"node_modules"
],
"include": [
".plasmo/index.d.ts",
"./**/*.ts",
"./**/*.tsx"
],
"compilerOptions": {
"paths": {
"~*": [
"./*"
],
"@/*": [
"./*"
]
},
"baseUrl": ".",
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}