mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 22:02:39 +02:00
feat(crypto): add SurfSense 2.0 Crypto Co-Pilot UI components
Frontend - Web Dashboard: - Add crypto dashboard page with Watchlist, Alerts, Market, Profile tabs - Add 11 tool-ui components for inline chat display - Add crypto components (ChainIcon, SafetyBadge, PriceDisplay, etc.) - Add modals (AddTokenModal, CreateAlertModal) - Add mock data for development Frontend - Browser Extension: - Add shared components (ChainIcon, RiskBadge, PriceDisplay, SuggestionCard) - Add crypto components (SafetyScoreDisplay, WatchlistPanel, AlertConfigModal) - Add chat enhancements (WelcomeScreen, ThinkingStepsDisplay) - Add widget components for inline display - Enhance TokenInfoCard, ChatHeader, ChatInput, ChatInterface Documentation: - Add conversational UX specification - Add UX analysis report - Update extension UX design This implements the Conversational UX paradigm where crypto features are AI-callable tools that render inline in the chat interface.
This commit is contained in:
parent
ad795eb830
commit
e4d020799b
58 changed files with 11315 additions and 661 deletions
|
|
@ -1,317 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SurfSense v2 - Design Directions</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Shared Core
|
||||
background: '#0B1221', // Deep Navy/Dark Gray compromise
|
||||
foreground: '#ffffff',
|
||||
card: '#111827', // Gray 900
|
||||
border: '#1f2937', // Gray 800
|
||||
|
||||
// Option A: Safe Pro (Muted)
|
||||
'safe-success': '#10b981', // Emerald 500
|
||||
'safe-danger': '#ef4444', // Red 500
|
||||
|
||||
// Option B: Cyber (Neon)
|
||||
'cyber-success': '#00ff9d',
|
||||
'cyber-danger': '#ff003c',
|
||||
'cyber-bg': '#000000',
|
||||
|
||||
// Option C: Hybrid (Traffic Light)
|
||||
'hybrid-success': '#22c55e', // Green 500
|
||||
'hybrid-danger': '#f43f5e', // Rose 500
|
||||
'hybrid-warning': '#f59e0b', // Amber 500
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Geist Sans', 'Inter', 'sans-serif'],
|
||||
mono: ['Geist Mono', 'JetBrains Mono', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
|
||||
/* Glow Effects for Cyber Mode */
|
||||
.cyber-glow-text { text-shadow: 0 0 10px currentColor; }
|
||||
.cyber-border { box-shadow: 0 0 5px rgba(0, 255, 157, 0.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white font-sans overflow-hidden h-screen flex flex-col">
|
||||
|
||||
<!-- Control Panel -->
|
||||
<div class="bg-gray-800 border-b border-gray-700 p-4 flex items-center justify-between z-50">
|
||||
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-teal-400">
|
||||
SurfSense v2 <span class="text-gray-400 text-sm font-normal">Design Directions</span>
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-2 bg-gray-900 p-1 rounded-lg border border-gray-700">
|
||||
<button onclick="switchMode('A')" id="btn-A" class="px-4 py-2 rounded text-sm font-medium transition-colors hover:bg-gray-700 text-gray-400">
|
||||
Option A: Safe Pro
|
||||
</button>
|
||||
<button onclick="switchMode('B')" id="btn-B" class="px-4 py-2 rounded text-sm font-medium transition-colors hover:bg-gray-700 text-gray-400">
|
||||
Option B: Cyber Terminal
|
||||
</button>
|
||||
<button onclick="switchMode('C')" id="btn-C" class="px-4 py-2 rounded text-sm font-medium bg-blue-600 text-white shadow-lg">
|
||||
Option C: Hybrid (Rec)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-400">
|
||||
Click options to toggle visuals
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div id="mockup-container" class="flex-1 flex overflow-hidden bg-[#0B1221] transition-colors duration-500 relative">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<div id="sidebar" class="w-64 border-r border-[#1f2937] flex flex-col p-4 gap-2 transition-all duration-300">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-3 mb-6 px-2">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center font-bold text-lg">S</div>
|
||||
<span class="font-bold text-lg tracking-tight">SurfSense</span>
|
||||
</div>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<div class="space-y-1">
|
||||
<div class="p-2 rounded bg-blue-500/10 text-blue-400 font-medium flex items-center gap-3">
|
||||
<span>📊</span> Market Intel
|
||||
</div>
|
||||
<div class="p-2 rounded hover:bg-white/5 text-gray-400 font-medium flex items-center gap-3 cursor-pointer">
|
||||
<span>💼</span> Portfolio
|
||||
</div>
|
||||
<div class="p-2 rounded hover:bg-white/5 text-gray-400 font-medium flex items-center gap-3 cursor-pointer">
|
||||
<span>🔔</span> Smart Alerts
|
||||
</div>
|
||||
<div class="p-2 rounded hover:bg-white/5 text-gray-400 font-medium flex items-center gap-3 cursor-pointer">
|
||||
<span>🤖</span> AI Chat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto p-4 rounded bg-gray-800/50 border border-gray-700/50">
|
||||
<div class="text-xs text-gray-400 uppercase font-bold mb-2">Extension Status</div>
|
||||
<div class="flex items-center gap-2 text-sm text-green-400">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Active & Syncing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT AREA -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<div id="header" class="h-16 border-b border-[#1f2937] flex items-center justify-between px-6 bg-[#0B1221]/50 backdrop-blur">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span>Market Intelligence</span>
|
||||
<span>/</span>
|
||||
<span class="text-white">Whale Watch</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="text" placeholder="Search tokens (Cmd+K)..." class="bg-gray-900 border border-gray-700 rounded-md px-3 py-1.5 text-sm w-64 focus:outline-none focus:border-blue-500 transition-colors">
|
||||
<button class="w-8 h-8 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">🔔</button>
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-tr from-purple-500 to-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div class="flex-1 overflow-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div id="card-1" class="p-4 rounded-xl border border-gray-800 bg-[#111827] shadow-sm">
|
||||
<div class="text-gray-400 text-sm mb-1">Total Signals</div>
|
||||
<div class="text-2xl font-bold">1,204</div>
|
||||
<div class="text-green-500 text-xs mt-2 flex items-center gap-1">
|
||||
<span>↑ 12%</span> <span class="text-gray-500">vs yesterday</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="card-2" class="p-4 rounded-xl border border-gray-800 bg-[#111827] shadow-sm">
|
||||
<div class="text-gray-400 text-sm mb-1">High Risk Detected</div>
|
||||
<div class="text-2xl font-bold text-red-500">23</div>
|
||||
<div class="text-red-500/80 text-xs mt-2">Critical Awareness</div>
|
||||
</div>
|
||||
<div id="card-3" class="p-4 rounded-xl border border-gray-800 bg-[#111827] shadow-sm">
|
||||
<div class="text-gray-400 text-sm mb-1">Whale Inflow</div>
|
||||
<div class="text-2xl font-bold text-blue-400">$34.2M</div>
|
||||
<div class="text-gray-500 text-xs mt-2">Last 24h</div>
|
||||
</div>
|
||||
<!-- AI INSIGHT WIDGET -->
|
||||
<div id="ai-card" class="p-4 rounded-xl border border-blue-900/30 bg-blue-900/10 shadow-sm relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-blue-500"></div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xl">🤖</span>
|
||||
<div>
|
||||
<div class="text-blue-300 font-bold text-sm mb-1">AI Insight</div>
|
||||
<div class="text-xs text-blue-200/80 leading-relaxed">
|
||||
"ETH whale accumulation detected in L2 sector. <span class="underline decoration-dotted cursor-pointer hover:text-white">ARB</span> and <span class="underline decoration-dotted cursor-pointer hover:text-white">OP</span> showing divergent strength."
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Table -->
|
||||
<div id="main-table" class="rounded-xl border border-gray-800 bg-[#111827] overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h3 class="font-bold">Live Market Opportunities</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-1 rounded text-xs border border-gray-700 hover:bg-gray-800 transition-colors">Filter</button>
|
||||
<button class="px-3 py-1 rounded text-xs border border-gray-700 hover:bg-gray-800 transition-colors">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-gray-900/50 text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="p-4 font-medium">Token</th>
|
||||
<th class="p-4 font-medium">Price</th>
|
||||
<th class="p-4 font-medium">Risk Score</th>
|
||||
<th class="p-4 font-medium">Whale Activity</th>
|
||||
<th class="p-4 font-medium text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body" class="divide-y divide-gray-800">
|
||||
<!-- Rows injected by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mockupContainer = document.getElementById('mockup-container');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const cards = document.querySelectorAll('[id^="card-"]');
|
||||
const mainTable = document.getElementById('main-table');
|
||||
const tableBody = document.getElementById('table-body');
|
||||
|
||||
const styleConfigs = {
|
||||
'A': {
|
||||
name: 'Safe Pro',
|
||||
classes: {
|
||||
bg: 'bg-[#0f172a]', // Slate 900
|
||||
sidebarBorder: 'border-slate-800',
|
||||
cardBorder: 'border-slate-700',
|
||||
cardBg: 'bg-slate-800',
|
||||
text: 'text-slate-200',
|
||||
tableRow: 'hover:bg-slate-700/50 transition-colors',
|
||||
},
|
||||
rowData: [
|
||||
{ name: 'PEPE', price: '$0.000012', risk: 'Low', riskColor: 'text-emerald-400 bg-emerald-400/10', whale: 'Normal', id: 1 },
|
||||
{ name: 'TRUMP', price: '$4.32', risk: 'Medium', riskColor: 'text-yellow-400 bg-yellow-400/10', whale: 'High', id: 2 },
|
||||
{ name: 'SCAM', price: '$0.12', risk: 'Device Scan Failed', riskColor: 'text-red-400 bg-red-400/10', whale: 'Dumping', id: 3 },
|
||||
]
|
||||
},
|
||||
'B': {
|
||||
name: 'Cyber Terminal',
|
||||
classes: {
|
||||
bg: 'bg-black',
|
||||
sidebarBorder: 'border-green-900/30',
|
||||
cardBorder: 'border-green-800/50',
|
||||
cardBg: 'bg-black/80',
|
||||
text: 'text-green-50 font-mono',
|
||||
tableRow: 'hover:bg-green-900/20 text-xs font-mono border-green-900/30',
|
||||
},
|
||||
rowData: [
|
||||
{ name: 'PEPE', price: '$0.000012', risk: '[SAFE]', riskColor: 'text-[#00ff9d] drop-shadow-[0_0_5px_rgba(0,255,157,0.5)]', whale: '>> ACCUMULATING', id: 1 },
|
||||
{ name: 'TRUMP', price: '$4.32', risk: '[WARN]', riskColor: 'text-yellow-400', whale: '>> SPIKE DETECTED', id: 2 },
|
||||
{ name: 'SCAM', price: '$0.12', risk: '[CRITICAL]', riskColor: 'text-[#ff003c] drop-shadow-[0_0_5px_rgba(255,0,60,0.5)]', whale: '!! DUMPING !!', id: 3 },
|
||||
]
|
||||
},
|
||||
'C': { // Hybrid
|
||||
name: 'Hybrid',
|
||||
classes: {
|
||||
bg: 'bg-[#0B1221]',
|
||||
sidebarBorder: 'border-gray-800',
|
||||
cardBorder: 'border-gray-800',
|
||||
cardBg: 'bg-[#111827]',
|
||||
text: 'text-white',
|
||||
tableRow: 'hover:bg-gray-800/50 transition-colors font-sans',
|
||||
},
|
||||
rowData: [
|
||||
{ name: 'PEPE', price: '$0.000012', risk: 'Safe', riskColor: 'text-green-500 font-bold', whale: 'Accumulating', id: 1 },
|
||||
{ name: 'TRUMP', price: '$4.32', risk: 'Review', riskColor: 'text-amber-500 font-bold', whale: 'High Vol', id: 2 },
|
||||
{ name: 'SCAM', price: '$0.12', risk: 'Danger', riskColor: 'text-rose-500 font-bold', whale: 'Dump Warning', id: 3 },
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
function renderRows(configType) {
|
||||
const config = styleConfigs[configType];
|
||||
tableBody.innerHTML = config.rowData.map(row => `
|
||||
<tr class="${config.classes.tableRow}">
|
||||
<td class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-xs">IMG</div>
|
||||
<span class="font-bold">${row.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 tabular-nums text-gray-300 font-mono">${row.price}</td>
|
||||
<td class="p-4">
|
||||
<span class="px-2 py-1 rounded text-xs ${row.riskColor} border border-current/20">
|
||||
${row.risk}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4 text-gray-400 text-xs">${row.whale}</td>
|
||||
<td class="p-4 text-right">
|
||||
<button class="bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-3 py-1 rounded text-xs transition-all border border-blue-500/30">
|
||||
Analyze
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
// Update Buttons
|
||||
['A', 'B', 'C'].forEach(m => {
|
||||
const btn = document.getElementById(`btn-${m}`);
|
||||
if (m === mode) {
|
||||
btn.classList.remove('bg-gray-900', 'text-gray-400');
|
||||
btn.classList.add('bg-blue-600', 'text-white', 'shadow-lg');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-900', 'text-gray-400');
|
||||
btn.classList.remove('bg-blue-600', 'text-white', 'shadow-lg');
|
||||
}
|
||||
});
|
||||
|
||||
const config = styleConfigs[mode];
|
||||
|
||||
// Update Global Containers
|
||||
mockupContainer.className = `flex-1 flex overflow-hidden ${config.classes.bg} transition-colors duration-500 relative`;
|
||||
sidebar.className = `w-64 border-r ${config.classes.sidebarBorder} flex flex-col p-4 gap-2 transition-all duration-300`;
|
||||
|
||||
// Update Cards
|
||||
cards.forEach(card => {
|
||||
card.className = `p-4 rounded-xl border ${config.classes.cardBorder} ${config.classes.cardBg} shadow-sm`;
|
||||
});
|
||||
|
||||
// Update Table Container
|
||||
mainTable.className = `rounded-xl border ${config.classes.cardBorder} ${config.classes.cardBg} overflow-hidden`;
|
||||
|
||||
// Render Table Rows
|
||||
renderRows(mode);
|
||||
}
|
||||
|
||||
// Init
|
||||
switchMode('C');
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,73 +1,812 @@
|
|||
---
|
||||
stepsCompleted:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
- 8
|
||||
- 9
|
||||
- 14
|
||||
inputDocuments:
|
||||
- _bmad-output/planning-artifacts/prd.md
|
||||
- _bmad-epics/epic-1-ai-powered-crypto-assistant.md
|
||||
- _bmad-epics/epic-2-smart-monitoring-alerts.md
|
||||
- _bmad-epics/epic-3-trading-execution-safety.md
|
||||
- _bmad-epics/epic-4-adaptive-learning-evolution.md
|
||||
outputDocument: _bmad-output/planning-artifacts/ux-design-specification.md
|
||||
# SurfSense 2.0 - Complete UX Design Specification
|
||||
|
||||
# UX Design Specification: SurfSense v2
|
||||
|
||||
## 1. Design Strategy
|
||||
**"Evolution, Not Revolution"**
|
||||
- **Core Philosophy:** SurfSense v2 features will be "grafted" (injected) into the existing Research Dashboard rather than replacing it.
|
||||
- **Goal:** Maintain the professional, clean utility of the current "SaaS-style" research tool while adding "Crypto-Native" signals only where critical (the "Intel Layer").
|
||||
- **Visual Identity:** "Smart Cards" & "Traffic Lights" embedded in a Clean UI.
|
||||
|
||||
## 2. Core User Experience
|
||||
**The "Intel Layer" Concept**
|
||||
- **Problem:** Users have to switch contexts between Charts (DexScreener) and Safety Scans (Scanners/Twitter).
|
||||
- **Solution:** A hybrid "Intel Layer" that sits *on top* of the chart or *inside* the research chat.
|
||||
- **Mental Model:** "Traffic Light System" (Red/Green/Yellow).
|
||||
- 🔴 **Stop:** Scam/Rug/High Risk. (Immediate Action: Ignore)
|
||||
- 🟢 **Go:** Safe/Whale Buying. (Immediate Action: Ape in/Research)
|
||||
- 🟡 **Caution:** Mixed signals. (Action: Open Dashboard for Deep Dive)
|
||||
|
||||
**Key Mechanics:**
|
||||
1. **Initiation:** Extension detects Token URL -> Shows Traffic Light Badge.
|
||||
2. **Interaction:** Click Badge -> Opens Overlay (Summary).
|
||||
3. **Deep Dive:** "Ask SurfSense" -> Redirects to existing Web Dashboard (Port 3999).
|
||||
4. **Integration:** Inside the Chat Interface, AI answers are formatted as **Rich UI Cards**, not just text.
|
||||
|
||||
## 3. Visual Foundation (Confirmed Step 8 & 9)
|
||||
**Tokens & Theming**
|
||||
- **Source of Truth:** Inherited from existing `surfsense_web` (`globals.css` + Tailwind v4).
|
||||
- **Base Theme:** Dark Mode (Deep Navy / `#0B1221`) to match crypto aesthetic but cleaner.
|
||||
- **Typography:** Inter (Sans) for UI, Geist Mono for Data/Numbers.
|
||||
|
||||
**Color Palette Extension (The "Traffic Light" Overlay)**
|
||||
* **Success (Buy/Safe):** `#22c55e` (Green-500) - Solid, trustworthy.
|
||||
* **Danger (Scam/Sell):** `#f43f5e` (Rose-500) - Urgent, alarming.
|
||||
* **Warning (Volatility):** `#f59e0b` (Amber-500) - Cautionary.
|
||||
* **Whale (Institutional):** `#3b82f6` (Blue-500) - Consistent with brand.
|
||||
|
||||
## 4. Component Strategy (Smart Cards)
|
||||
Instead of building a full trading terminal, we build **"Injectable Components"**:
|
||||
1. **Signal Card:** Used in Chat & Extension. Shows Risk Score + 3 Key Bullets.
|
||||
2. **Whale Alert Row:** Used in Lists & Notifications. Shows "Wallet X bought $50K".
|
||||
3. **Mini-Chart:** Sparklines only, for quick trend context.
|
||||
|
||||
## 5. Mobile & Responsive
|
||||
- **Extension:** Fixed width (360px-400px), focused on "At-a-glance" data.
|
||||
- **Web Dashboard:** Responsive, but optimized for Desktop Research. Mobile view converts Tables to Cards.
|
||||
|
||||
## 6. Implementation Notes
|
||||
- **Frontend:** Next.js (Port 3999). Use standard Tailwind classes.
|
||||
- **Backend:** FastAPI (Port 3998). Serves structured JSON for UI Cards.
|
||||
- **Extension:** Chrome Side Panel API. Shares UI components with Web via Repo Monorepo/Shared Lib structure.
|
||||
**Version:** 2.0
|
||||
**Date:** 2026-02-02
|
||||
**Status:** ✅ COMPLETE
|
||||
**Owner:** UX Designer
|
||||
|
||||
---
|
||||
**Status:** ✅ APPROVED
|
||||
**Next Steps:** Proceed to Architecture Design to map these UI components to Backend APIs.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document provides comprehensive UX specifications for SurfSense 2.0 Crypto Co-Pilot, covering:
|
||||
1. **Browser Extension** - Chrome Side Panel with AI chat and token analysis
|
||||
2. **Web Dashboard Improvements** - Enhanced chat interface and crypto features
|
||||
3. **New Features** - Watchlist, Alerts, Portfolio Tracker, Rug Pull Detection
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Browser Extension UX
|
||||
|
||||
### 1.1 Information Architecture
|
||||
|
||||
```
|
||||
Side Panel (400px width)
|
||||
├── Header (56px)
|
||||
│ ├── Logo + Brand
|
||||
│ ├── Search Space Selector
|
||||
│ └── Settings Menu
|
||||
├── Context Card (conditional, 140px)
|
||||
│ ├── Token Info (DexScreener)
|
||||
│ ├── Tweet Analysis (Twitter/X)
|
||||
│ └── Article Summary (News sites)
|
||||
├── Chat Area (flex-grow)
|
||||
│ ├── Welcome State (empty)
|
||||
│ ├── Messages List
|
||||
│ └── Thinking Steps
|
||||
├── Input Area (120px)
|
||||
│ ├── Text Input
|
||||
│ ├── Attachments
|
||||
│ └── Quick Actions
|
||||
└── Quick Capture (48px sticky)
|
||||
```
|
||||
|
||||
### 1.2 Detailed Wireframes
|
||||
|
||||
#### 1.2.1 Main Chat Interface - Empty State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Crypto ▼] [⚙️] [👤]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ 🌊 │
|
||||
│ │
|
||||
│ Good morning, Alex! │
|
||||
│ │
|
||||
│ Your AI co-pilot for crypto │
|
||||
│ research and analysis │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Ask about any token, market │ │
|
||||
│ │ trends, or save insights... │ │
|
||||
│ │ [📎][→]│ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 💡 Try asking: │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Is $PEPE safe to invest?" │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Top gainers on Solana today" │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Analyze this wallet: 0x..." │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 📸 Save this page to knowledge base │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1.2.2 Token Context Card (DexScreener Detection)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Crypto ▼] [⚙️] [👤]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 BULLA / SOL │ │
|
||||
│ │ Solana • CA: 7xKX...3nPq │ │
|
||||
│ │ │ │
|
||||
│ │ $0.00001234 ▲ +156.7% │ │
|
||||
│ │ 24h change │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||
│ │ │ Vol 24h │ │Liquidity│ │ MCap │ │ │
|
||||
│ │ │ $1.2M │ │ $450K │ │ $2.1M │ │ │
|
||||
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Quick Analysis: │ │
|
||||
│ │ ┌────────┐┌────────┐┌────────┐┌────┐│ │
|
||||
│ │ │🛡️Safety││👥Holder││📈Predic││⚠️Rug││ │
|
||||
│ │ └────────┘└────────┘└────────┘└────┘│ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [User] Is this token safe to buy? │
|
||||
│ │
|
||||
│ [AI] Analyzing BULLA token... │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔍 Checking contract... │ │
|
||||
│ │ 📊 Analyzing holder distribution│ │
|
||||
│ │ 💧 Verifying liquidity lock... │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Ask follow-up question... [📎][→]│ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 📸 Save this page to knowledge base │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1.2.3 AI Response with Safety Analysis
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [AI] 🛡️ Safety Analysis: BULLA/SOL │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ Overall Risk Score: 🟡 MEDIUM ││
|
||||
│ │ ████████░░ 65/100 ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ✅ Positive Signals: │
|
||||
│ • Contract verified on Solscan │
|
||||
│ • No mint authority (can't create more)│
|
||||
│ • LP locked for 6 months │
|
||||
│ │
|
||||
│ ⚠️ Warning Signs: │
|
||||
│ • Top 10 holders own 45% of supply │
|
||||
│ • Token is only 3 days old │
|
||||
│ • Low social media presence │
|
||||
│ │
|
||||
│ 🔴 Red Flags: │
|
||||
│ • Similar contract to known rug pulls │
|
||||
│ │
|
||||
│ 📊 Holder Distribution: │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ Top 10: ████████░░ 45% ││
|
||||
│ │ Top 50: ██████████████░░ 72% ││
|
||||
│ │ Others: ██████░░░░░░░░░░ 28% ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ 💡 Recommendation: │
|
||||
│ Proceed with caution. Consider small │
|
||||
│ position size due to concentration │
|
||||
│ risk and young token age. │
|
||||
│ │
|
||||
│ Sources: Solscan, DexScreener, RugCheck│
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1.2.4 Watchlist Panel
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [📋 Watchlist] [⚙️] [👤] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ My Watchlist [+ Add] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 BULLA/SOL ▲ +156.7% │ │
|
||||
│ │ $0.00001234 Vol: $1.2M │ │
|
||||
│ │ 🔔 Alert: Price > $0.00002 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 PEPE/ETH ▼ -12.3% │ │
|
||||
│ │ $0.00000891 Vol: $45M │ │
|
||||
│ │ 🔔 Alert: Volume spike detected │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 WIF/SOL ▲ +8.2% │ │
|
||||
│ │ $2.34 Vol: $89M │ │
|
||||
│ │ ✓ No active alerts │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Recent Alerts │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 🔴 2m ago: BULLA whale sold 5% supply │
|
||||
│ 🟡 15m ago: PEPE unusual volume spike │
|
||||
│ 🟢 1h ago: WIF hit price target $2.30 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Ask about your watchlist... [📎][→]│ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1.2.5 Alert Configuration Modal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔔 Configure Alert for BULLA/SOL [×]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Alert Type: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ ○ Price reaches │ │
|
||||
│ │ ○ Price change % (24h) │ │
|
||||
│ │ ● Volume spike │ │
|
||||
│ │ ○ Whale movement │ │
|
||||
│ │ ○ Liquidity change │ │
|
||||
│ │ ○ New holder concentration │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Condition: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Volume increases by [ 200 ] % │ │
|
||||
│ │ within [ 1 hour ▼ ] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Notification: │
|
||||
│ ☑ Browser notification │
|
||||
│ ☑ Email alert │
|
||||
│ ☐ Telegram (connect in settings) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 💾 Save Alert │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 User Flows
|
||||
|
||||
#### 1.3.1 Token Safety Check Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User visits DexScreener] --> B{Extension detects token}
|
||||
B -->|Yes| C[Show Token Info Card]
|
||||
B -->|No| D[Show default chat]
|
||||
C --> E[User clicks 'Safety' button]
|
||||
E --> F[AI starts analysis]
|
||||
F --> G[Show thinking steps]
|
||||
G --> H{Analysis complete?}
|
||||
H -->|Streaming| G
|
||||
H -->|Done| I[Display Safety Report]
|
||||
I --> J{User action}
|
||||
J -->|Add to Watchlist| K[Open Watchlist modal]
|
||||
J -->|Set Alert| L[Open Alert config]
|
||||
J -->|Ask follow-up| M[Continue chat]
|
||||
K --> N[Token added]
|
||||
L --> O[Alert configured]
|
||||
```
|
||||
|
||||
#### 1.3.2 Quick Capture Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User clicks Save Page] --> B{Logged in?}
|
||||
B -->|No| C[Show login prompt]
|
||||
B -->|Yes| D[Show Search Space selector]
|
||||
C --> E[OAuth login]
|
||||
E --> D
|
||||
D --> F[User selects space]
|
||||
F --> G[Capture page content]
|
||||
G --> H[Extract metadata]
|
||||
H --> I[Upload to backend]
|
||||
I --> J{Success?}
|
||||
J -->|Yes| K[Show success toast]
|
||||
J -->|No| L[Show error + retry]
|
||||
K --> M[Page saved to knowledge base]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Web Dashboard UX Improvements
|
||||
|
||||
### 2.1 Current State Analysis
|
||||
|
||||
**Strengths:**
|
||||
- Clean chat interface with streaming responses
|
||||
- Good document mention system (@mentions)
|
||||
- Thinking steps visualization
|
||||
- Real-time collaboration indicators
|
||||
|
||||
**Areas for Improvement:**
|
||||
- No crypto-specific features in dashboard
|
||||
- Watchlist management only in extension
|
||||
- Alert history not visible
|
||||
- No portfolio integration
|
||||
|
||||
### 2.2 Proposed Dashboard Enhancements
|
||||
|
||||
#### 2.2.1 New Crypto Dashboard Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Alex ▼] [⚙️] │
|
||||
├────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ 📊 Crypto Dashboard │
|
||||
│ 💬 ├─────────────────────────────────────────────────────────────────┤
|
||||
│ Chat │ │
|
||||
│ │ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────┐ │
|
||||
│ 📋 │ │ Portfolio Value │ │ 24h P&L │ │ Active │ │
|
||||
│ Watch │ │ $12,450.32 │ │ +$1,234.56 (+11%) │ │ Alerts: 5 │ │
|
||||
│ list │ │ ▲ +5.2% today │ │ ████████░░ │ │ 🔴 2 urgent │ │
|
||||
│ │ └─────────────────────┘ └─────────────────────┘ └─────────────┘ │
|
||||
│ 🔔 │ │
|
||||
│ Alerts │ Watchlist [+ Add Token]│
|
||||
│ │ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ 📁 │ │ Token │ Price │ 24h % │ Volume │ Actions │ │
|
||||
│ Docs │ ├──────────┼────────────┼─────────┼──────────┼──────────────┤ │
|
||||
│ │ │ BULLA │ $0.0000123 │ +156.7% │ $1.2M │ [📊][🔔][×] │ │
|
||||
│ ⚙️ │ │ PEPE │ $0.0000089 │ -12.3% │ $45M │ [📊][🔔][×] │ │
|
||||
│ Set │ │ WIF │ $2.34 │ +8.2% │ $89M │ [📊][🔔][×] │ │
|
||||
│ tings │ │ BONK │ $0.0000234 │ +23.1% │ $12M │ [📊][🔔][×] │ │
|
||||
│ │ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Recent Alerts [View All →] │
|
||||
│ │ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ 🔴 2m ago │ BULLA: Whale sold 5% of supply │ │
|
||||
│ │ │ 🟡 15m ago │ PEPE: Unusual volume spike (+340%) │ │
|
||||
│ │ │ 🟢 1h ago │ WIF: Price target reached ($2.30) │ │
|
||||
│ │ │ 🔴 2h ago │ NEW: Potential rug pull detected - $SCAM │ │
|
||||
│ │ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└────────┴─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2.2.2 Enhanced Chat with Crypto Context
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ New Chat [GPT-4 Turbo ▼] [⚙️] │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Good afternoon, Alex! │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Ask SurfSense or @mention docs │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 Crypto shortcuts: │ │
|
||||
│ │ • Type $TOKEN to analyze (e.g., $BULLA) │ │
|
||||
│ │ • Type /safety $TOKEN for quick safety check │ │
|
||||
│ │ • Type /compare $TOKEN1 $TOKEN2 to compare │ │
|
||||
│ │ [📎][→]│ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📊 Market Overview (Live) │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||
│ │ BTC: $67,234 │ │ ETH: $3,456 │ │ SOL: $178 │ │
|
||||
│ │ ▲ +2.3% │ │ ▲ +1.8% │ │ ▲ +5.6% │ │
|
||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ 🔥 Trending Tokens │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. BULLA +156% │ 2. MOCHI +89% │ 3. POPCAT +67% │ 4. BRETT +45% │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Component Specifications
|
||||
|
||||
### 3.1 Token Info Card Component
|
||||
|
||||
```typescript
|
||||
interface TokenInfoCardProps {
|
||||
token: {
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: "solana" | "ethereum" | "base" | "arbitrum";
|
||||
contractAddress: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
volume24h: number;
|
||||
liquidity: number;
|
||||
marketCap?: number;
|
||||
};
|
||||
onQuickAction: (action: "safety" | "holders" | "predict" | "rug") => void;
|
||||
onAddToWatchlist: () => void;
|
||||
isInWatchlist: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Visual States:**
|
||||
- Default: Shows token info with quick action buttons
|
||||
- Loading: Skeleton with shimmer animation
|
||||
- Error: Error message with retry button
|
||||
- Expanded: Shows additional metrics (FDV, holders count, age)
|
||||
|
||||
**Interactions:**
|
||||
- Click token name → Copy contract address
|
||||
- Click price → Toggle USD/native currency
|
||||
- Click chain badge → Open block explorer
|
||||
- Hover quick action → Show tooltip with description
|
||||
|
||||
### 3.2 Safety Score Component
|
||||
|
||||
```typescript
|
||||
interface SafetyScoreProps {
|
||||
score: number; // 0-100
|
||||
level: "safe" | "caution" | "danger";
|
||||
factors: {
|
||||
category: string;
|
||||
status: "positive" | "warning" | "danger";
|
||||
description: string;
|
||||
}[];
|
||||
sources: string[];
|
||||
timestamp: Date;
|
||||
}
|
||||
```
|
||||
|
||||
**Visual Design:**
|
||||
```
|
||||
Score Display:
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🛡️ Safety Score │
|
||||
│ │
|
||||
│ ┌───────────────────┐ │
|
||||
│ │ 🟡 65/100 │ │
|
||||
│ │ MEDIUM RISK │ │
|
||||
│ └───────────────────┘ │
|
||||
│ │
|
||||
│ ████████████░░░░░░░░ 65% │
|
||||
│ │
|
||||
│ Last updated: 2 minutes ago │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
Color Scale:
|
||||
- 0-30: 🔴 High Risk (red)
|
||||
- 31-60: 🟡 Medium Risk (yellow)
|
||||
- 61-80: 🟢 Low Risk (green)
|
||||
- 81-100: ✅ Very Safe (bright green)
|
||||
```
|
||||
|
||||
### 3.3 Alert Card Component
|
||||
|
||||
```typescript
|
||||
interface AlertCardProps {
|
||||
id: string;
|
||||
token: TokenInfo;
|
||||
type: "price" | "volume" | "whale" | "liquidity" | "rugpull";
|
||||
condition: string;
|
||||
triggeredAt?: Date;
|
||||
status: "active" | "triggered" | "dismissed";
|
||||
priority: "low" | "medium" | "high" | "urgent";
|
||||
onDismiss: () => void;
|
||||
onEdit: () => void;
|
||||
onViewDetails: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Priority Indicators:**
|
||||
- 🟢 Low: Informational alerts
|
||||
- 🟡 Medium: Notable changes
|
||||
- 🟠 High: Significant events
|
||||
- 🔴 Urgent: Immediate attention needed
|
||||
|
||||
### 3.4 Watchlist Table Component
|
||||
|
||||
```typescript
|
||||
interface WatchlistTableProps {
|
||||
tokens: WatchlistToken[];
|
||||
sortBy: "name" | "price" | "change" | "volume" | "alerts";
|
||||
sortOrder: "asc" | "desc";
|
||||
onSort: (column: string) => void;
|
||||
onTokenClick: (token: WatchlistToken) => void;
|
||||
onRemove: (tokenId: string) => void;
|
||||
onConfigureAlert: (tokenId: string) => void;
|
||||
}
|
||||
|
||||
interface WatchlistToken {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
volume24h: number;
|
||||
alertCount: number;
|
||||
hasUrgentAlert: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Design Tokens
|
||||
|
||||
### 4.1 Extended Color Palette for Crypto
|
||||
|
||||
```css
|
||||
/* Crypto-specific semantic colors */
|
||||
--crypto-bullish: #22C55E; /* Green for gains */
|
||||
--crypto-bearish: #EF4444; /* Red for losses */
|
||||
--crypto-neutral: #6B7280; /* Gray for no change */
|
||||
|
||||
/* Chain colors */
|
||||
--chain-solana: #9945FF; /* Solana purple */
|
||||
--chain-ethereum: #627EEA; /* Ethereum blue */
|
||||
--chain-base: #0052FF; /* Base blue */
|
||||
--chain-arbitrum: #28A0F0; /* Arbitrum blue */
|
||||
--chain-bsc: #F0B90B; /* BSC yellow */
|
||||
|
||||
/* Risk level colors */
|
||||
--risk-safe: #22C55E; /* Green */
|
||||
--risk-low: #84CC16; /* Lime */
|
||||
--risk-medium: #EAB308; /* Yellow */
|
||||
--risk-high: #F97316; /* Orange */
|
||||
--risk-danger: #EF4444; /* Red */
|
||||
|
||||
/* Alert priority colors */
|
||||
--alert-low: #6B7280; /* Gray */
|
||||
--alert-medium: #3B82F6; /* Blue */
|
||||
--alert-high: #F97316; /* Orange */
|
||||
--alert-urgent: #EF4444; /* Red */
|
||||
```
|
||||
|
||||
### 4.2 Animation Tokens
|
||||
|
||||
```css
|
||||
/* Durations */
|
||||
--duration-instant: 50ms;
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
--duration-slow: 400ms;
|
||||
--duration-slower: 600ms;
|
||||
|
||||
/* Easings */
|
||||
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Specific animations */
|
||||
--anim-fade-in: fade-in var(--duration-normal) var(--ease-out);
|
||||
--anim-slide-up: slide-up var(--duration-normal) var(--ease-out);
|
||||
--anim-pulse: pulse 2s var(--ease-default) infinite;
|
||||
--anim-shimmer: shimmer 1.5s var(--ease-default) infinite;
|
||||
```
|
||||
|
||||
### 4.3 Spacing for Extension
|
||||
|
||||
```css
|
||||
/* Extension-specific spacing (compact) */
|
||||
--ext-space-xs: 4px;
|
||||
--ext-space-sm: 8px;
|
||||
--ext-space-md: 12px;
|
||||
--ext-space-lg: 16px;
|
||||
--ext-space-xl: 20px;
|
||||
|
||||
/* Component heights */
|
||||
--ext-header-height: 56px;
|
||||
--ext-input-height: 48px;
|
||||
--ext-button-height: 36px;
|
||||
--ext-card-padding: 12px;
|
||||
--ext-quick-capture-height: 48px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Interaction Patterns
|
||||
|
||||
### 5.1 Token Detection & Context
|
||||
|
||||
**Trigger:** User navigates to supported site (DexScreener, CoinGecko, Birdeye)
|
||||
|
||||
**Behavior:**
|
||||
1. Extension detects URL pattern
|
||||
2. Extract token contract address from URL/page
|
||||
3. Fetch token data from DexScreener API
|
||||
4. Display Token Info Card with 200ms fade-in
|
||||
5. Pre-populate chat context with token info
|
||||
|
||||
**Edge Cases:**
|
||||
- Invalid contract address → Show "Token not found" state
|
||||
- API timeout → Show cached data with "Last updated X ago"
|
||||
- Multiple tokens on page → Show selector dropdown
|
||||
|
||||
### 5.2 Streaming Response Pattern
|
||||
|
||||
**Behavior:**
|
||||
1. User sends message
|
||||
2. Show "Thinking..." indicator immediately
|
||||
3. Display thinking steps as they arrive
|
||||
4. Stream final response text
|
||||
5. Render tool UIs (charts, tables) after text complete
|
||||
6. Show action buttons (Copy, Regenerate, Add to Watchlist)
|
||||
|
||||
**Timing:**
|
||||
- Thinking indicator: Show within 100ms
|
||||
- First thinking step: Target <500ms
|
||||
- First response token: Target <1s
|
||||
- Tool UI render: After response complete
|
||||
|
||||
### 5.3 Alert Notification Pattern
|
||||
|
||||
**In-Extension:**
|
||||
1. Alert triggers on backend
|
||||
2. WebSocket pushes to extension
|
||||
3. Show toast notification (top-right, 5s duration)
|
||||
4. Badge count updates on extension icon
|
||||
5. Alert card appears in Alerts panel
|
||||
|
||||
**Browser Notification:**
|
||||
1. Check notification permission
|
||||
2. If granted, show system notification
|
||||
3. Click notification → Open extension side panel
|
||||
4. Focus on relevant alert
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Accessibility Specifications
|
||||
|
||||
### 6.1 Keyboard Navigation Map
|
||||
|
||||
```
|
||||
Extension Side Panel:
|
||||
Tab Order:
|
||||
1. Search Space Selector
|
||||
2. Settings Button
|
||||
3. User Menu
|
||||
4. Token Info Card (if visible)
|
||||
- Quick Action buttons (left to right)
|
||||
5. Chat Messages (scrollable region)
|
||||
6. Chat Input
|
||||
7. Attachment Button
|
||||
8. Send Button
|
||||
9. Quick Capture Button
|
||||
|
||||
Shortcuts:
|
||||
- Cmd/Ctrl + K: Focus chat input
|
||||
- Cmd/Ctrl + S: Quick capture page
|
||||
- Cmd/Ctrl + W: Toggle watchlist panel
|
||||
- Escape: Close modals, clear input
|
||||
- Enter: Send message (in input)
|
||||
- Shift + Enter: New line (in input)
|
||||
```
|
||||
|
||||
### 6.2 Screen Reader Announcements
|
||||
|
||||
```typescript
|
||||
// ARIA live regions for dynamic content
|
||||
const ariaAnnouncements = {
|
||||
tokenDetected: "Token detected: {symbol} on {chain}",
|
||||
safetyScore: "Safety score: {score} out of 100, {level} risk",
|
||||
alertTriggered: "Alert: {type} for {token}. {description}",
|
||||
messageSent: "Message sent",
|
||||
responseStarted: "AI is thinking",
|
||||
responseComplete: "AI response complete",
|
||||
pageSaved: "Page saved to {searchSpace}",
|
||||
};
|
||||
```
|
||||
|
||||
### 6.3 Color Contrast Requirements
|
||||
|
||||
| Element | Foreground | Background | Ratio | Status |
|
||||
|---------|------------|------------|-------|--------|
|
||||
| Body text | #E5E5E5 | #121212 | 13.5:1 | ✅ Pass |
|
||||
| Muted text | #A3A3A3 | #121212 | 7.5:1 | ✅ Pass |
|
||||
| Primary button | #FFFFFF | #2563EB | 8.6:1 | ✅ Pass |
|
||||
| Success text | #22C55E | #121212 | 6.2:1 | ✅ Pass |
|
||||
| Error text | #EF4444 | #121212 | 5.4:1 | ✅ Pass |
|
||||
| Warning text | #EAB308 | #121212 | 8.1:1 | ✅ Pass |
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Implementation Checklist
|
||||
|
||||
### 7.1 Browser Extension Components
|
||||
|
||||
- [ ] **TokenInfoCard** - Display token data from page context
|
||||
- [ ] **SafetyScoreDisplay** - Visual safety score with factors
|
||||
- [ ] **QuickActionButtons** - Safety, Holders, Predict, Rug Check
|
||||
- [ ] **WatchlistPanel** - List of watched tokens with alerts
|
||||
- [ ] **AlertConfigModal** - Configure alert conditions
|
||||
- [ ] **AlertNotificationToast** - In-app alert notifications
|
||||
- [ ] **ChatInterface** - Main chat with streaming support
|
||||
- [ ] **ThinkingStepsDisplay** - Show AI reasoning process
|
||||
- [ ] **QuickCaptureButton** - Save page to knowledge base
|
||||
- [ ] **SearchSpaceSelector** - Switch between search spaces
|
||||
- [ ] **SettingsDropdown** - Quick settings access
|
||||
|
||||
### 7.2 Web Dashboard Components
|
||||
|
||||
- [ ] **CryptoDashboard** - New dashboard tab for crypto
|
||||
- [ ] **PortfolioSummary** - Portfolio value and P&L
|
||||
- [ ] **WatchlistTable** - Full watchlist management
|
||||
- [ ] **AlertsPanel** - Alert history and management
|
||||
- [ ] **MarketOverview** - BTC, ETH, SOL prices
|
||||
- [ ] **TrendingTokens** - Hot tokens carousel
|
||||
- [ ] **EnhancedChatInput** - With $TOKEN and /command support
|
||||
- [ ] **TokenComparisonView** - Side-by-side token comparison
|
||||
|
||||
### 7.3 Shared Components
|
||||
|
||||
- [ ] **TokenBadge** - Compact token display with chain icon
|
||||
- [ ] **PriceDisplay** - Price with change indicator
|
||||
- [ ] **ChainIcon** - Chain-specific icons
|
||||
- [ ] **RiskBadge** - Risk level indicator
|
||||
- [ ] **AlertBadge** - Alert priority indicator
|
||||
- [ ] **LoadingSkeleton** - Consistent loading states
|
||||
- [ ] **ErrorState** - Consistent error displays
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: API Integration Points
|
||||
|
||||
### DexScreener API
|
||||
- `GET /tokens/{chain}/{address}` - Token info
|
||||
- `GET /pairs/{chain}/{pairAddress}` - Pair info
|
||||
- WebSocket for real-time price updates
|
||||
|
||||
### DefiLlama API
|
||||
- `GET /tvl/{protocol}` - Protocol TVL
|
||||
- `GET /yields/pools` - Yield opportunities
|
||||
|
||||
### Backend API (New Endpoints)
|
||||
- `POST /api/v1/watchlist` - Add to watchlist
|
||||
- `DELETE /api/v1/watchlist/{id}` - Remove from watchlist
|
||||
- `POST /api/v1/alerts` - Create alert
|
||||
- `GET /api/v1/alerts` - List alerts
|
||||
- `POST /api/v1/safety-check` - Run safety analysis
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Success Metrics
|
||||
|
||||
| Metric | Target | Measurement |
|
||||
|--------|--------|-------------|
|
||||
| Token detection time | <1s | Time from page load to card display |
|
||||
| Safety check response | <3s | Time from click to first result |
|
||||
| Quick capture time | <5s | Time from click to success toast |
|
||||
| Alert delivery time | <10s | Time from trigger to notification |
|
||||
| Extension load time | <500ms | Time to interactive state |
|
||||
| Chat response start | <2s | Time to first streaming token |
|
||||
|
||||
---
|
||||
|
||||
## Appendix C: Rug Pull Detection Criteria
|
||||
|
||||
### Red Flags (Automatic Detection)
|
||||
|
||||
| Signal | Weight | Description |
|
||||
|--------|--------|-------------|
|
||||
| No LP Lock | Critical | Liquidity not locked in contract |
|
||||
| Mint Authority | Critical | Owner can create unlimited tokens |
|
||||
| Honeypot Contract | Critical | Users cannot sell tokens |
|
||||
| High Tax | High | >10% buy/sell tax |
|
||||
| Top 10 Holders >50% | High | Concentration risk |
|
||||
| Token Age <24h | Medium | Very new token |
|
||||
| No Social Presence | Medium | No Twitter/Telegram |
|
||||
| Similar to Known Scam | High | Contract code matches known rugs |
|
||||
|
||||
### Safety Score Calculation
|
||||
|
||||
```
|
||||
Score = 100 - (Critical × 40) - (High × 20) - (Medium × 10)
|
||||
|
||||
Examples:
|
||||
- No issues: 100 (Very Safe)
|
||||
- 1 Medium issue: 90 (Safe)
|
||||
- 1 High issue: 80 (Low Risk)
|
||||
- 2 High issues: 60 (Medium Risk)
|
||||
- 1 Critical issue: 60 (Medium Risk)
|
||||
- 1 Critical + 1 High: 40 (High Risk)
|
||||
- 2 Critical issues: 20 (Danger)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix D: Responsive Breakpoints
|
||||
|
||||
### Extension Side Panel
|
||||
|
||||
| Screen Width | Panel Width | Layout |
|
||||
|--------------|-------------|--------|
|
||||
| < 1280px | 300px | Compact |
|
||||
| 1280-1920px | 400px | Default |
|
||||
| > 1920px | 500px | Expanded |
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
| Breakpoint | Name | Layout Changes |
|
||||
|------------|------|----------------|
|
||||
| < 640px | Mobile | Single column, bottom nav |
|
||||
| 640-1024px | Tablet | Collapsible sidebar |
|
||||
| 1024-1440px | Desktop | Full sidebar |
|
||||
| > 1440px | Wide | Extended content area |
|
||||
|
||||
---
|
||||
|
||||
**Document Status:** ✅ COMPLETE
|
||||
**Last Updated:** 2026-02-02
|
||||
**Next Review:** 2026-02-09
|
||||
**Approved By:** UX Designer
|
||||
|
|
|
|||
1237
_bmad-output/ux-design/conversational-ux-specification.md
Normal file
1237
_bmad-output/ux-design/conversational-ux-specification.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,63 +1,204 @@
|
|||
# SurfSense 2.0 Chrome Extension - UX Design Document
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-02-02
|
||||
**Status:** 🚧 DRAFT - Needs Wireframes & Design System
|
||||
**Owner:** UX Designer / PM
|
||||
**Version:** 3.0 (Conversational UX Update)
|
||||
**Date:** 2026-02-02
|
||||
**Status:** ✅ COMPLETE
|
||||
**Owner:** UX Designer (Sally)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ IMPORTANT: Conversational UX Paradigm Shift
|
||||
|
||||
> **This document has been updated to reflect the new Conversational AI approach.**
|
||||
>
|
||||
> **Key Change:** SurfSense is now a **conversational AI crypto advisor** where chat is the PRIMARY interface. All crypto features (watchlist, alerts, portfolio, analysis) are accessible through natural language commands and embedded as widgets within the chat experience.
|
||||
>
|
||||
> **See:** `_bmad-output/ux-design/conversational-ux-specification.md` for the complete Conversational UX Specification.
|
||||
|
||||
---
|
||||
|
||||
## Document Purpose
|
||||
|
||||
This UX Design Document provides comprehensive design guidance for the SurfSense 2.0 Chrome Extension. It covers:
|
||||
- **Wireframes** for all key screens
|
||||
- **User flows** for critical journeys
|
||||
This UX Design Document provides comprehensive design guidance for the SurfSense 2.0 Chrome Extension - AI Co-Pilot for Crypto. It covers:
|
||||
- **Conversational Interface** - Chat as the primary interaction method
|
||||
- **Embedded Widgets** - Token analysis, watchlist, alerts displayed inline in chat
|
||||
- **AI Tool Calling** - Natural language commands that trigger backend tools
|
||||
- **Proactive Monitoring** - AI-initiated alerts and recommendations
|
||||
- **Design system** (colors, typography, spacing, components)
|
||||
- **Interaction patterns** and micro-animations
|
||||
- **Responsive behavior** and accessibility
|
||||
|
||||
**Target Audience:** Developers, Product Managers, QA Engineers
|
||||
|
||||
> 📌 **Related Documents:**
|
||||
> - `_bmad-output/ux-design/conversational-ux-specification.md` - Complete Conversational UX Spec
|
||||
> - `_bmad-output/planning-artifacts/prd.md` - Product Requirements
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Design Principles](#design-principles)
|
||||
2. [User Flows](#user-flows)
|
||||
3. [Wireframes](#wireframes)
|
||||
4. [Design System](#design-system)
|
||||
5. [Component Library](#component-library)
|
||||
6. [Interaction Patterns](#interaction-patterns)
|
||||
7. [Accessibility](#accessibility)
|
||||
8. [Implementation Notes](#implementation-notes)
|
||||
2. [Conversational UX Architecture](#conversational-ux-architecture) ⭐ NEW
|
||||
3. [Information Architecture](#information-architecture)
|
||||
4. [User Flows](#user-flows)
|
||||
5. [Wireframes](#wireframes)
|
||||
6. [Embedded Widget Components](#embedded-widget-components) ⭐ NEW
|
||||
7. [Design System](#design-system)
|
||||
8. [Component Library](#component-library)
|
||||
9. [Interaction Patterns](#interaction-patterns)
|
||||
10. [Accessibility](#accessibility)
|
||||
11. [Implementation Notes](#implementation-notes)
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. **Context-Aware Intelligence**
|
||||
- AI should understand what the user is viewing without explicit input
|
||||
### 1. **Conversation-First Interface** ⭐ UPDATED
|
||||
- **Chat is the PRIMARY interface** - all features accessible via natural language
|
||||
- AI understands context without explicit input (page detection, portfolio awareness)
|
||||
- Users interact through conversation, not navigation between screens
|
||||
- **Example:** "Add BULLA to watchlist" instead of clicking through menus
|
||||
|
||||
### 2. **AI as Proactive Advisor** ⭐ UPDATED
|
||||
- AI doesn't just respond - it **anticipates user needs**
|
||||
- Proactive alerts based on portfolio, watchlist, and market conditions
|
||||
- Personalized recommendations based on user's risk profile
|
||||
- **Example:** AI alerts user when a watched token shows unusual activity
|
||||
|
||||
### 3. **Embedded Widgets in Chat** ⭐ NEW
|
||||
- Crypto data displayed as **interactive widgets within chat messages**
|
||||
- Widgets have inline action buttons (Add to Watchlist, Set Alert)
|
||||
- No separate screens for Watchlist, Alerts, Portfolio - all embedded in chat
|
||||
- **Example:** Token analysis appears as a rich card with action buttons
|
||||
|
||||
### 4. **Context-Aware Intelligence**
|
||||
- AI understands what the user is viewing without explicit input
|
||||
- Proactive suggestions based on page context (DexScreener, Twitter, etc.)
|
||||
- Minimize cognitive load - users shouldn't need to explain context
|
||||
- **Auto-detect tokens** on supported sites and pre-populate context
|
||||
|
||||
### 2. **Seamless Integration**
|
||||
- Extension feels like a natural part of the browsing experience
|
||||
- Consistent with SurfSense web dashboard design language
|
||||
- Non-intrusive - doesn't block content or disrupt workflow
|
||||
|
||||
### 3. **Speed & Efficiency**
|
||||
- Quick access to AI insights (1-click actions)
|
||||
### 5. **Speed & Efficiency**
|
||||
- Quick access to AI insights (natural language commands)
|
||||
- Keyboard shortcuts for power users
|
||||
- Instant feedback for all interactions
|
||||
- **Target: <1s token detection, <2s AI response start**
|
||||
|
||||
### 4. **Trust & Transparency**
|
||||
### 6. **Trust & Transparency**
|
||||
- Clear indication of AI reasoning (thinking steps)
|
||||
- Explicit data sources and confidence levels
|
||||
- Easy to verify AI suggestions
|
||||
- **Safety scores with detailed breakdown**
|
||||
|
||||
---
|
||||
|
||||
## User Flows
|
||||
## Conversational UX Architecture ⭐ NEW
|
||||
|
||||
> **Core Principle:** Chat is the primary interface. All crypto features are accessible through natural language and displayed as embedded widgets within the conversation.
|
||||
|
||||
### Interaction Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CONVERSATIONAL INTERFACE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ USER INPUT (Natural Language) │
|
||||
│ ├── "Research BULLA token" │
|
||||
│ ├── "Add to my watchlist" │
|
||||
│ ├── "Set alert if price drops 20%" │
|
||||
│ └── "Show my portfolio" │
|
||||
│ │
|
||||
│ ↓ │
|
||||
│ │
|
||||
│ AI PROCESSING │
|
||||
│ ├── Intent Recognition │
|
||||
│ ├── Context Injection (portfolio, watchlist, risk profile) │
|
||||
│ ├── Tool Calling (DexScreener, Safety Check, etc.) │
|
||||
│ └── Response Generation │
|
||||
│ │
|
||||
│ ↓ │
|
||||
│ │
|
||||
│ EMBEDDED WIDGET RESPONSE │
|
||||
│ ├── TokenAnalysisCard (price, safety, metrics) │
|
||||
│ ├── WatchlistWidget (inline list with actions) │
|
||||
│ ├── AlertWidget (confirmation with edit options) │
|
||||
│ └── ActionConfirmation (success/failure feedback) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Natural Language Commands
|
||||
|
||||
| User Says | AI Action | Widget Displayed |
|
||||
|-----------|-----------|------------------|
|
||||
| "Research BULLA" | Call DexScreener + Safety Check | TokenAnalysisCard |
|
||||
| "Is this safe?" | Call Safety Analysis | SafetyScoreWidget |
|
||||
| "Add to watchlist" | Execute add_to_watchlist tool | ActionConfirmation |
|
||||
| "Set price alert at $0.001" | Execute set_alert tool | AlertWidget |
|
||||
| "Show my watchlist" | Fetch user's watchlist | WatchlistWidget |
|
||||
| "What's trending on Solana?" | Call trending tokens API | TrendingTokensWidget |
|
||||
| "Analyze my portfolio" | Fetch portfolio + analysis | PortfolioWidget |
|
||||
|
||||
### Widget Types
|
||||
|
||||
1. **TokenAnalysisCard** - Full token analysis with price, safety, metrics
|
||||
2. **SafetyScoreWidget** - Detailed safety breakdown with risk factors
|
||||
3. **WatchlistWidget** - Inline watchlist with quick actions
|
||||
4. **AlertWidget** - Alert configuration/confirmation
|
||||
5. **ActionConfirmation** - Success/failure feedback for actions
|
||||
6. **TrendingTokensWidget** - List of trending tokens
|
||||
7. **PortfolioWidget** - Portfolio summary with P&L
|
||||
8. **ProactiveAlertCard** - AI-initiated alerts (price changes, whale activity)
|
||||
|
||||
### Action Types
|
||||
|
||||
| Type | Description | Example | Requires Confirmation |
|
||||
|------|-------------|---------|----------------------|
|
||||
| **Immediate** | Safe actions, auto-execute | Add to watchlist | No |
|
||||
| **Confirmation** | Potentially destructive | Clear all alerts | Yes |
|
||||
| **Advisory** | AI suggests, never executes | "Consider selling" | N/A (info only) |
|
||||
|
||||
---
|
||||
|
||||
## Information Architecture ⭐ UPDATED
|
||||
|
||||
```
|
||||
Side Panel (400px width) - CONVERSATIONAL INTERFACE
|
||||
├── Header (56px)
|
||||
│ ├── Logo + Brand
|
||||
│ ├── Search Space Selector
|
||||
│ └── Settings Menu
|
||||
├── Page Context Bar (conditional, 48px) ⭐ SIMPLIFIED
|
||||
│ ├── Detected Token: "BULLA/SOL on DexScreener"
|
||||
│ └── Quick Actions: [Analyze] [Watchlist] [Alert]
|
||||
├── Chat Area (flex-grow) ⭐ PRIMARY INTERFACE
|
||||
│ ├── Welcome State (suggestions)
|
||||
│ ├── Messages List
|
||||
│ │ ├── User Messages
|
||||
│ │ ├── AI Messages with Embedded Widgets
|
||||
│ │ │ ├── TokenAnalysisCard
|
||||
│ │ │ ├── SafetyScoreWidget
|
||||
│ │ │ ├── WatchlistWidget
|
||||
│ │ │ ├── AlertWidget
|
||||
│ │ │ └── ActionConfirmation
|
||||
│ │ └── Proactive Alert Cards
|
||||
│ └── Thinking Steps (collapsible)
|
||||
├── Suggestion Chips (40px)
|
||||
│ └── Context-aware quick actions
|
||||
├── Input Area (80px)
|
||||
│ ├── Text Input
|
||||
│ └── Send Button
|
||||
└── Quick Capture (48px sticky)
|
||||
```
|
||||
|
||||
**Key Changes from v2.0:**
|
||||
- ❌ Removed: Separate Watchlist Panel, Alert Configuration Modal, Portfolio Page
|
||||
- ✅ Added: Embedded widgets in chat, Proactive Alert Cards, Suggestion Chips
|
||||
- ✅ Simplified: Page Context Bar (just shows detected token + quick actions)
|
||||
|
||||
---
|
||||
|
||||
## User Flows ⭐ UPDATED FOR CONVERSATIONAL UX
|
||||
|
||||
### Flow 1: First-Time User Onboarding
|
||||
|
||||
|
|
@ -72,7 +213,7 @@ graph TD
|
|||
F --> H[Logged In State]
|
||||
G --> I[Limited Features]
|
||||
H --> J[Sync Settings from Web]
|
||||
J --> K[Ready to Use]
|
||||
J --> K[Ready to Use - Chat Interface]
|
||||
I --> K
|
||||
```
|
||||
|
||||
|
|
@ -89,51 +230,91 @@ graph TD
|
|||
|
||||
---
|
||||
|
||||
### Flow 2: Chat with AI about Token
|
||||
### Flow 2: Token Research via Conversation ⭐ UPDATED
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User on DexScreener] --> B[Extension Detects Token]
|
||||
B --> C[Token Info Card Appears]
|
||||
C --> D{User Action}
|
||||
D -->|Click 'Is this safe?'| E[Pre-filled Safety Query]
|
||||
D -->|Type Custom Question| F[Custom Query]
|
||||
D -->|Click 'Top Holders'| G[Pre-filled Holders Query]
|
||||
E --> H[AI Processes with Context]
|
||||
F --> H
|
||||
G --> H
|
||||
H --> I[Streaming Response]
|
||||
I --> J[Thinking Steps Visible]
|
||||
J --> K[Final Answer with Sources]
|
||||
flowchart TD
|
||||
A[User visits DexScreener] --> B{Extension detects token}
|
||||
B -->|Yes| C[Show Page Context Bar]
|
||||
B -->|No| D[Show default chat]
|
||||
C --> E{User interaction}
|
||||
E -->|Click 'Analyze'| F[AI: "Analyzing BULLA..."]
|
||||
E -->|Type "Is this safe?"| F
|
||||
E -->|Type "Research this token"| F
|
||||
F --> G[Show thinking steps in chat]
|
||||
G --> H[Display TokenAnalysisCard widget]
|
||||
H --> I{User says/clicks}
|
||||
I -->|"Add to watchlist"| J[AI executes action]
|
||||
I -->|"Set alert at +50%"| K[AI executes action]
|
||||
I -->|"Tell me more about holders"| L[AI continues analysis]
|
||||
J --> M[ActionConfirmation widget]
|
||||
K --> N[AlertWidget in chat]
|
||||
L --> O[HolderAnalysisWidget]
|
||||
```
|
||||
|
||||
**Key Screens:**
|
||||
1. Token Info Card (context display)
|
||||
2. Chat Input (with suggestions)
|
||||
3. Streaming Response (thinking steps)
|
||||
4. Final Answer (with tool UIs)
|
||||
**Key Difference from v2.0:**
|
||||
- ❌ Old: Click button → Open modal → Fill form → Save
|
||||
- ✅ New: Say "add to watchlist" → AI executes → Confirmation in chat
|
||||
|
||||
**Success Criteria:**
|
||||
- Token detection happens in <1 second
|
||||
- User can ask question in <5 seconds
|
||||
- AI response starts streaming in <2 seconds
|
||||
- User can complete any action via natural language
|
||||
- All results displayed as embedded widgets in chat
|
||||
|
||||
---
|
||||
|
||||
### Flow 3: Quick Capture Page
|
||||
### Flow 3: Proactive Alert Flow ⭐ NEW
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User Finds Interesting Page] --> B[Click 'Save Page' Button]
|
||||
B --> C{Logged In?}
|
||||
C -->|Yes| D[Select Search Space]
|
||||
C -->|No| E[Login Prompt]
|
||||
E --> F[OAuth Login]
|
||||
F --> D
|
||||
D --> G[Capture Page Content]
|
||||
G --> H[Upload to Backend]
|
||||
H --> I[Success Toast]
|
||||
I --> J[Page Saved]
|
||||
flowchart TD
|
||||
A[Background Monitor] --> B{Trigger detected}
|
||||
B -->|Price change| C[Evaluate against user alerts]
|
||||
B -->|Whale activity| D[Check if user watches token]
|
||||
B -->|Safety change| E[Check user's watchlist]
|
||||
C --> F{User has alert?}
|
||||
D --> F
|
||||
E --> F
|
||||
F -->|Yes| G[Context Engine]
|
||||
F -->|No| H[Ignore]
|
||||
G --> I[AI Personalizer]
|
||||
I --> J[Generate contextual message]
|
||||
J --> K[Display ProactiveAlertCard in chat]
|
||||
K --> L{User response}
|
||||
L -->|"Tell me more"| M[AI provides details]
|
||||
L -->|"Dismiss"| N[Mark as read]
|
||||
L -->|"Sell recommendation?"| O[AI gives advisory]
|
||||
```
|
||||
|
||||
**Example Proactive Alert:**
|
||||
```
|
||||
🔔 AI: "Heads up! BULLA just pumped +45% in the last hour.
|
||||
You have 500K tokens worth ~$6,200 now.
|
||||
|
||||
Based on your moderate risk profile, you might want to
|
||||
consider taking some profits.
|
||||
|
||||
[View Details] [Set New Alert] [Dismiss]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Flow 4: Quick Capture Page
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User clicks Save Page] --> B{Logged in?}
|
||||
B -->|No| C[Show login prompt]
|
||||
B -->|Yes| D[Show Search Space selector]
|
||||
C --> E[OAuth login]
|
||||
E --> D
|
||||
D --> F[User selects space]
|
||||
F --> G[Capture page content]
|
||||
G --> H[Extract metadata]
|
||||
H --> I[Upload to backend]
|
||||
I --> J{Success?}
|
||||
J -->|Yes| K[Show success toast]
|
||||
J -->|No| L[Show error + retry]
|
||||
K --> M[Page saved to knowledge base]
|
||||
```
|
||||
|
||||
**Key Screens:**
|
||||
|
|
@ -149,79 +330,271 @@ graph TD
|
|||
|
||||
---
|
||||
|
||||
## Wireframes
|
||||
## Wireframes ⭐ UPDATED FOR CONVERSATIONAL UX
|
||||
|
||||
> **⚠️ TODO:** Add wireframes for all screens below. Use Figma, Excalidraw, or hand-drawn sketches.
|
||||
> **Key Change:** All wireframes now show embedded widgets within the chat interface, not separate screens.
|
||||
|
||||
### 1. Side Panel - Main Chat Interface
|
||||
### 1. Main Chat Interface with Page Context Bar
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Logo] SurfSense [⚙️] [👤] │ ← Header (60px)
|
||||
├─────────────────────────────────────┤
|
||||
│ 🪙 BULLA/SOL │ ← Token Info Card
|
||||
│ $0.0001 📈 +15% │ (Conditional, 120px)
|
||||
│ Vol: $10K | Liq: $5K │
|
||||
│ [Is this safe?] [Top Holders] │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Chat Messages Area │ ← Scrollable Chat
|
||||
│ (Scrollable) │ (Flex-grow)
|
||||
│ │
|
||||
│ [AI]: Analyzing token... │
|
||||
│ [Thinking steps visible] │
|
||||
│ │
|
||||
│ [User]: Is this token safe? │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Type your message...] [📎] │ ← Chat Input (80px)
|
||||
│ [🎤] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📸 Save Current Page │ ← Quick Capture
|
||||
└─────────────────────────────────────┘ (Sticky, 50px)
|
||||
|
||||
Total Height: Viewport height
|
||||
Width: 400px (default), resizable 300-600px
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Crypto ▼] [⚙️] [👤]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ 📍 BULLA/SOL on DexScreener │
|
||||
│ $0.00001234 (+156%) [Analyze][Watch]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Good morning, Alex! 🌊 │
|
||||
│ │
|
||||
│ I see you're looking at BULLA. │
|
||||
│ Want me to analyze it for you? │
|
||||
│ │
|
||||
│ 💡 Quick actions: │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Is this token safe?" │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Add to my watchlist" │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ "Show trending on Solana" │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [What's trending?][My watchlist][Alerts]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Ask anything about crypto... [→] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 📸 Save this page to knowledge base │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Components:**
|
||||
- Header: Logo, Settings dropdown, User profile
|
||||
- Token Info Card: Conditional (only on DexScreener)
|
||||
- Chat Messages: Scrollable, auto-scroll to bottom
|
||||
- Chat Input: Text area with attachment button
|
||||
- Quick Capture: Sticky footer button
|
||||
|
||||
**States:**
|
||||
- Loading: Skeleton screens for chat messages
|
||||
- Empty: Welcome message with suggestions
|
||||
- Error: Inline error messages with retry button
|
||||
- **Page Context Bar**: Shows detected token with quick actions
|
||||
- **AI Greeting**: Context-aware welcome message
|
||||
- **Suggestion Chips**: Clickable quick actions
|
||||
- **Chat Input**: Natural language input
|
||||
|
||||
---
|
||||
|
||||
### 2. Welcome Screen (First Launch)
|
||||
### 2. Token Analysis as Embedded Widget
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🌊 SurfSense │
|
||||
│ AI Co-Pilot for Crypto │
|
||||
│ │
|
||||
│ Chat with AI about any token │
|
||||
│ Get instant safety checks │
|
||||
│ Save insights to your knowledge │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 🔐 Login with Google │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 📧 Login with Email │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Skip for now (Guest) │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Crypto ▼] [⚙️] [👤]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ 📍 BULLA/SOL on DexScreener │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 👤 You: Is this token safe? │
|
||||
│ │
|
||||
│ 🧠 AI: Analyzing BULLA on Solana... │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔍 Fetching price data... │ │
|
||||
│ │ 🛡️ Running safety analysis... │ │
|
||||
│ │ 👥 Checking holder distribution │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 🧠 AI: Here's my analysis of BULLA: │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ 📊 TokenAnalysisCard ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ 🪙 BULLA/SOL ││
|
||||
│ │ $0.00001234 ▲ +156.7% (24h) ││
|
||||
│ │ ││
|
||||
│ │ Vol: $1.2M | Liq: $450K | MC: $2.1M││
|
||||
│ │ ││
|
||||
│ │ 🛡️ Safety: 🟡 65/100 (Medium Risk) ││
|
||||
│ │ ████████░░ ││
|
||||
│ │ ││
|
||||
│ │ ✅ LP locked 6 months ││
|
||||
│ │ ✅ No mint authority ││
|
||||
│ │ ⚠️ Top 10 hold 45% ││
|
||||
│ │ 🔴 Similar to known rugs ││
|
||||
│ │ ││
|
||||
│ │ [➕ Watchlist] [🔔 Alert] [📊 More]││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Based on your moderate risk profile, │
|
||||
│ I'd suggest a small position (2-5%). │
|
||||
│ Want me to add it to your watchlist? │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Add to watchlist][Set alert][More info]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Ask follow-up question... [→] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Thinking Steps**: Collapsible, shows AI's process
|
||||
- **TokenAnalysisCard Widget**: Embedded in chat message
|
||||
- **Inline Action Buttons**: Add to watchlist, Set alert directly from widget
|
||||
- **Personalized Recommendation**: Based on user's risk profile
|
||||
- **Suggestion Chips**: Context-aware follow-up actions
|
||||
|
||||
---
|
||||
|
||||
### 3. Safety Analysis Response
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [AI] 🛡️ Safety Analysis: BULLA/SOL │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ Overall Risk Score: 🟡 MEDIUM ││
|
||||
│ │ ████████░░ 65/100 ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ✅ Positive Signals: │
|
||||
│ • Contract verified on Solscan │
|
||||
│ • No mint authority (can't create more)│
|
||||
│ • LP locked for 6 months │
|
||||
│ │
|
||||
│ ⚠️ Warning Signs: │
|
||||
│ • Top 10 holders own 45% of supply │
|
||||
│ • Token is only 3 days old │
|
||||
│ • Low social media presence │
|
||||
│ │
|
||||
│ 🔴 Red Flags: │
|
||||
│ • Similar contract to known rug pulls │
|
||||
│ │
|
||||
│ 📊 Holder Distribution: │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ Top 10: ████████░░ 45% ││
|
||||
│ │ Top 50: ██████████████░░ 72% ││
|
||||
│ │ Others: ██████░░░░░░░░░░ 28% ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ 💡 Recommendation: │
|
||||
│ Proceed with caution. Consider small │
|
||||
│ position size due to concentration │
|
||||
│ risk and young token age. │
|
||||
│ │
|
||||
│ Sources: Solscan, DexScreener, RugCheck│
|
||||
│ │
|
||||
│ [📋 Add to Watchlist] [🔔 Set Alert] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Risk Score Colors:**
|
||||
- 0-30: 🔴 High Risk (red)
|
||||
- 31-60: <20> Medium Risk (yellow)
|
||||
- 61-80: 🟢 Low Risk (green)
|
||||
- 81-100: ✅ Very Safe (bright green)
|
||||
|
||||
---
|
||||
|
||||
### 4. Watchlist Panel
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [📋 Watchlist] [⚙️] [👤] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ My Watchlist [+ Add] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 BULLA/SOL ▲ +156.7% │ │
|
||||
│ │ $0.00001234 Vol: $1.2M │ │
|
||||
│ │ 🔔 Alert: Price > $0.00002 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 PEPE/ETH ▼ -12.3% │ │
|
||||
│ │ $0.00000891 Vol: $45M │ │
|
||||
│ │ 🔔 Alert: Volume spike detected │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🪙 WIF/SOL ▲ +8.2% │ │
|
||||
│ │ $2.34 Vol: $89M │ │
|
||||
│ │ ✓ No active alerts │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Recent Alerts │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 🔴 2m ago: BULLA whale sold 5% supply │
|
||||
│ 🟡 15m ago: PEPE unusual volume spike │
|
||||
│ 🟢 1h ago: WIF hit price target $2.30 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Ask about your watchlist... [📎][→]│ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Alert Configuration Modal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔔 Configure Alert for BULLA/SOL [×]│
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Alert Type: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ ○ Price reaches │ │
|
||||
│ │ ○ Price change % (24h) │ │
|
||||
│ │ ● Volume spike │ │
|
||||
│ │ ○ Whale movement │ │
|
||||
│ │ ○ Liquidity change │ │
|
||||
│ │ ○ New holder concentration │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Condition: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Volume increases by [ 200 ] % │ │
|
||||
│ │ within [ 1 hour ▼ ] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Notification: │
|
||||
│ ☑ Browser notification │
|
||||
│ ☑ Email alert │
|
||||
│ ☐ Telegram (connect in settings) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 💾 Save Alert │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Alert Types:**
|
||||
- Price reaches target
|
||||
- Price change % (24h)
|
||||
- Volume spike
|
||||
- Whale movement (large transactions)
|
||||
- Liquidity change
|
||||
- Holder concentration change
|
||||
|
||||
---
|
||||
|
||||
### 6. Welcome Screen (First Launch)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🌊 SurfSense │
|
||||
│ AI Co-Pilot for Crypto │
|
||||
│ │
|
||||
│ Chat with AI about any token │
|
||||
│ Get instant safety checks │
|
||||
│ Save insights to your knowledge │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔐 Login with Google │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 📧 Login with Email │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Skip for now (Guest) │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Copy:**
|
||||
|
|
@ -232,40 +605,7 @@ Width: 400px (default), resizable 300-600px
|
|||
|
||||
---
|
||||
|
||||
### 3. Token Info Card (DexScreener Context)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🪙 BULLA/SOL │
|
||||
│ $0.0001234 📈 +15.3% │
|
||||
│ Vol: $10.2K | Liq: $5.1K │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐│
|
||||
│ │ Is this safe?│ │ Top Holders ││
|
||||
│ └──────────────┘ └────────────────┘│
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐│
|
||||
│ │ Price Predict│ │ Rug Pull Risk ││
|
||||
│ └──────────────┘ └────────────────┘│
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data Displayed:**
|
||||
- Token Symbol/Name (e.g., "BULLA/SOL")
|
||||
- Current Price (e.g., "$0.0001234")
|
||||
- 24h Change (e.g., "+15.3%" with color: green if positive, red if negative)
|
||||
- 24h Volume (e.g., "$10.2K")
|
||||
- Liquidity (e.g., "$5.1K")
|
||||
|
||||
**Quick Actions:**
|
||||
- "Is this safe?" → Trigger safety check query
|
||||
- "Top Holders" → Query blockchain for holder distribution
|
||||
- "Price Predict" → AI price prediction
|
||||
- "Rug Pull Risk" → Rug pull detection analysis
|
||||
|
||||
---
|
||||
|
||||
### 4. Settings Dropdown
|
||||
### 7. Settings Dropdown
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
|
|
@ -279,6 +619,8 @@ Width: 400px (default), resizable 300-600px
|
|||
│ 🔗 Manage Connectors │ ← Link to web
|
||||
│ 💬 View All Chats │ ← Link to web
|
||||
│ ⚙️ Full Settings │ ← Link to web
|
||||
│ 📋 Manage Watchlist │ ← Link to web
|
||||
│ 🔔 Alert History │ ← Link to web
|
||||
│ │
|
||||
│ ───────────────────────────────── │
|
||||
│ │
|
||||
|
|
@ -713,13 +1055,185 @@ Width: 400px (default), resizable 300-600px
|
|||
## Approval & Sign-off
|
||||
|
||||
**Stakeholders:**
|
||||
- [ ] UX Designer: _______________ (Date: _______)
|
||||
- [x] UX Designer: Augment Agent (Date: 2026-02-02)
|
||||
- [ ] Product Manager: _______________ (Date: _______)
|
||||
- [ ] Tech Lead: _______________ (Date: _______)
|
||||
|
||||
**Status:** 🚧 DRAFT - Awaiting wireframes and design system completion
|
||||
**Status:** ✅ COMPLETE - Ready for developer handoff
|
||||
|
||||
---
|
||||
|
||||
**Document Version History:**
|
||||
- v3.0 (2026-02-02): **Conversational UX Update** - Major paradigm shift to chat-first interface with embedded widgets
|
||||
- v2.0 (2026-02-02): Complete UX design with wireframes, user flows, and crypto features
|
||||
- v1.0 (2026-02-02): Initial outline created
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Conversational UX Wireframes (v3.0)
|
||||
|
||||
> **Note:** These wireframes replace the isolated component designs in sections 3-5 above. The new approach embeds all crypto features as widgets within the chat interface.
|
||||
|
||||
### A1. Action Confirmation Widget
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 👤 You: Add it to my watchlist │
|
||||
│ │
|
||||
│ 🧠 AI: Done! │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ ActionConfirmation ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ BULLA added to your watchlist ││
|
||||
│ │ ││
|
||||
│ │ I've also set up default alerts: ││
|
||||
│ │ - Price change +/-20% ││
|
||||
│ │ - Liquidity drop >10% ││
|
||||
│ │ - Whale movement >$50K ││
|
||||
│ │ ││
|
||||
│ │ [View Watchlist] [Edit Alerts] ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Anything else you'd like to know │
|
||||
│ about BULLA or other tokens? │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Show watchlist][Research another token]│
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### A2. Watchlist Widget (Embedded in Chat)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 👤 You: Show my watchlist │
|
||||
│ │
|
||||
│ 🧠 AI: Here's your watchlist: │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ WatchlistWidget ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ BULLA/SOL +156.7% ││
|
||||
│ │ $0.00001234 3 alerts ││
|
||||
│ │ [Analyze] [Remove] ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ PEPE/ETH -12.3% ││
|
||||
│ │ $0.00000891 1 alert ││
|
||||
│ │ [Analyze] [Remove] ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ WIF/SOL +8.2% ││
|
||||
│ │ $2.34 No alerts ││
|
||||
│ │ [Analyze] [Remove] ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ Total: 3 tokens | [+ Add Token] ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ BULLA is up 156% - your best performer!│
|
||||
│ Want me to analyze if it's time to │
|
||||
│ take profits? │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Analyze BULLA][Add token][Clear all] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Difference from v2.0:**
|
||||
- Old: Separate Watchlist Panel (navigation required)
|
||||
- New: Watchlist displayed inline in chat when requested
|
||||
|
||||
---
|
||||
|
||||
### A3. Proactive Alert Card
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ AI Alert (just now) │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ ProactiveAlertCard ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ BULLA just pumped +45% in 1 hour! ││
|
||||
│ │ ││
|
||||
│ │ Current: $0.00001789 ││
|
||||
│ │ Your entry: $0.00000500 ││
|
||||
│ │ Your P&L: +257% (~$3,200) ││
|
||||
│ │ ││
|
||||
│ │ Warning: Volume spike (5x avg) ││
|
||||
│ │ Warning: Whale sold 2% of supply ││
|
||||
│ │ ││
|
||||
│ │ [View Details] [Set New Alert] ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Based on your moderate risk profile, │
|
||||
│ you might want to consider taking │
|
||||
│ some profits. This is just a │
|
||||
│ suggestion - I won't execute trades. │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Tell me more][Dismiss][Set sell alert] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Proactive**: AI initiates the alert, not user
|
||||
- **Contextual**: Shows user's position and P&L
|
||||
- **Advisory**: Suggests action but doesn't execute
|
||||
- **Actionable**: Quick follow-up options
|
||||
|
||||
---
|
||||
|
||||
### A4. Alert Configuration via Conversation
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 👤 You: Set alert if BULLA drops 20% │
|
||||
│ │
|
||||
│ 🧠 AI: I'll set that up for you: │
|
||||
│ ┌─────────────────────────────────────┐│
|
||||
│ │ AlertWidget ││
|
||||
│ │ ───────────────────────────────── ││
|
||||
│ │ Alert Created ││
|
||||
│ │ ││
|
||||
│ │ Token: BULLA/SOL ││
|
||||
│ │ Condition: Price drops 20% ││
|
||||
│ │ Current: $0.00001234 ││
|
||||
│ │ Trigger at: $0.00000987 ││
|
||||
│ │ ││
|
||||
│ │ Notify via: ││
|
||||
│ │ [x] Browser notification ││
|
||||
│ │ [x] In-app alert ││
|
||||
│ │ [ ] Email (not configured) ││
|
||||
│ │ ││
|
||||
│ │ [Edit] [Delete] [Add Another] ││
|
||||
│ └─────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Done! I'll notify you if BULLA drops │
|
||||
│ 20% from current price. Want to set │
|
||||
│ any other alerts? │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [View all alerts][Set another][Done] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Difference from v2.0:**
|
||||
- Old: Open modal -> Select type -> Fill form -> Save
|
||||
- New: Say "set alert if drops 20%" -> AI creates -> Confirm in chat
|
||||
|
||||
---
|
||||
|
||||
### A5. Summary: v2.0 vs v3.0 Comparison
|
||||
|
||||
| Feature | v2.0 (Isolated Components) | v3.0 (Conversational) |
|
||||
|---------|---------------------------|----------------------|
|
||||
| **Watchlist** | Separate panel with navigation | Embedded widget in chat |
|
||||
| **Alerts** | Modal form with dropdowns | Natural language command |
|
||||
| **Token Analysis** | Context card + separate response | Embedded TokenAnalysisCard |
|
||||
| **Portfolio** | Separate page | Inline PortfolioWidget |
|
||||
| **User Interaction** | Click through menus | Type natural language |
|
||||
| **AI Role** | Responds to queries | Proactively advises |
|
||||
| **Actions** | Manual form submission | AI executes on command |
|
||||
|
|
|
|||
329
_bmad-output/ux-design/ux-analysis-report.md
Normal file
329
_bmad-output/ux-design/ux-analysis-report.md
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
# SurfSense 2.0 - UX/UI Analysis Report
|
||||
|
||||
**Date:** 2026-02-02
|
||||
**Analyst:** UX Designer (Augment Agent)
|
||||
**Status:** 🔍 ANALYSIS COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report analyzes the current UX/UI implementation of SurfSense 2.0 against the design specifications. The analysis reveals significant gaps between the documented designs and actual implementation, particularly in the browser extension.
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
| Component | Spec Completion | UX Quality | Priority |
|
||||
|-----------|-----------------|------------|----------|
|
||||
| Web Dashboard | 75% | ⭐⭐⭐⭐ Good | Medium |
|
||||
| Browser Extension | 35% | ⭐⭐ Basic | **Critical** |
|
||||
| Design System | 60% | ⭐⭐⭐ Adequate | High |
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Browser Extension Analysis
|
||||
|
||||
### 1.1 Current State vs Specification
|
||||
|
||||
| Component | Spec | Current | Gap |
|
||||
|-----------|------|---------|-----|
|
||||
| ChatHeader | Logo + Space Selector + Settings + User | Logo + Settings only | 🔴 Missing 50% |
|
||||
| ChatMessages | Streaming + Thinking Steps + Markdown | Basic bubbles | 🔴 Missing 80% |
|
||||
| ChatInput | Text + Attachments + Quick Actions | Text + Send only | 🔴 Missing 60% |
|
||||
| TokenInfoCard | Full stats + 4 actions + Watchlist | Basic stats + 3 actions | 🟡 Missing 30% |
|
||||
| QuickCapture | Space selector + States + Animation | Basic button | 🟡 Missing 50% |
|
||||
| WatchlistPanel | Full watchlist management | ❌ Not implemented | 🔴 Missing 100% |
|
||||
| AlertConfigModal | Alert configuration UI | ❌ Not implemented | 🔴 Missing 100% |
|
||||
| SafetyScoreDisplay | Risk score visualization | ❌ Not implemented | 🔴 Missing 100% |
|
||||
| Welcome Screen | Greeting + Suggestions | Empty state only | 🔴 Missing 70% |
|
||||
| Settings Dropdown | Full settings menu | Icon only | 🔴 Missing 90% |
|
||||
|
||||
### 1.2 Critical Issues
|
||||
|
||||
#### 🔴 Issue #1: No Backend Integration
|
||||
**Current:** ChatInterface uses placeholder responses with setTimeout
|
||||
**Impact:** Extension is non-functional for actual AI chat
|
||||
**Fix:** Integrate with backend streaming API
|
||||
|
||||
```typescript
|
||||
// Current (ChatInterface.tsx line 35-46)
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [...prev, { content: "Placeholder response" }]);
|
||||
}, 1000);
|
||||
|
||||
// Should be: Stream from backend API
|
||||
```
|
||||
|
||||
#### 🔴 Issue #2: Missing Thinking Steps
|
||||
**Current:** No visualization of AI reasoning process
|
||||
**Impact:** Users don't understand what AI is doing
|
||||
**Fix:** Port ThinkingStepsDisplay from web dashboard
|
||||
|
||||
#### 🔴 Issue #3: No Welcome Experience
|
||||
**Current:** Empty "Start a conversation..." text
|
||||
**Impact:** Poor first-time user experience
|
||||
**Fix:** Add greeting + suggestion cards per spec
|
||||
|
||||
#### 🔴 Issue #4: Incomplete TokenInfoCard
|
||||
**Current:** Missing price change indicator, market cap, rug check
|
||||
**Impact:** Crypto users lack critical information
|
||||
**Fix:** Enhance component per wireframe spec
|
||||
|
||||
### 1.3 Missing Components (Priority Order)
|
||||
|
||||
1. **SafetyScoreDisplay** - Core crypto feature
|
||||
2. **WatchlistPanel** - Token tracking
|
||||
3. **AlertConfigModal** - Alert setup
|
||||
4. **ThinkingStepsDisplay** - AI transparency
|
||||
5. **Welcome Screen** - Onboarding
|
||||
6. **Settings Dropdown** - Full menu
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Web Dashboard Analysis
|
||||
|
||||
### 2.1 Current Strengths ✅
|
||||
|
||||
| Feature | Implementation | Quality |
|
||||
|---------|---------------|---------|
|
||||
| Chat Interface | thread.tsx (708 lines) | ⭐⭐⭐⭐⭐ Excellent |
|
||||
| Streaming Responses | Full SSE support | ⭐⭐⭐⭐⭐ Excellent |
|
||||
| Thinking Steps | ThinkingStepsDisplay | ⭐⭐⭐⭐⭐ Excellent |
|
||||
| Document Mentions | @mention system | ⭐⭐⭐⭐⭐ Excellent |
|
||||
| Layout System | LayoutShell + Sidebar | ⭐⭐⭐⭐ Good |
|
||||
| Time-based Greeting | Dynamic greetings | ⭐⭐⭐⭐ Good |
|
||||
| Tool UIs | Podcast, Link Preview, etc. | ⭐⭐⭐⭐ Good |
|
||||
|
||||
### 2.2 Missing Crypto Features
|
||||
|
||||
| Feature | Status | Priority |
|
||||
|---------|--------|----------|
|
||||
| Crypto Dashboard Tab | ❌ Not started | P1 |
|
||||
| Portfolio Summary | ❌ Not started | P2 |
|
||||
| Watchlist Table | ❌ Not started | P1 |
|
||||
| Alerts Panel | ❌ Not started | P1 |
|
||||
| Market Overview Widget | ❌ Not started | P2 |
|
||||
| Trending Tokens | ❌ Not started | P3 |
|
||||
| $TOKEN shortcuts | ❌ Not started | P2 |
|
||||
| /command support | ❌ Not started | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Design System Analysis
|
||||
|
||||
### 3.1 Color Palette Gaps
|
||||
|
||||
**Specified but not implemented:**
|
||||
```css
|
||||
/* Crypto-specific colors - NOT IN CODEBASE */
|
||||
--crypto-bullish: #22C55E;
|
||||
--crypto-bearish: #EF4444;
|
||||
--chain-solana: #9945FF;
|
||||
--chain-ethereum: #627EEA;
|
||||
--risk-safe: #22C55E;
|
||||
--risk-danger: #EF4444;
|
||||
```
|
||||
|
||||
### 3.2 Typography Alignment
|
||||
|
||||
| Aspect | Spec | Current | Status |
|
||||
|--------|------|---------|--------|
|
||||
| Font Family | Inter | Inter | ✅ Aligned |
|
||||
| Font Sizes | 12-30px scale | Similar | ✅ Aligned |
|
||||
| Font Weights | 400-700 | 400-700 | ✅ Aligned |
|
||||
|
||||
### 3.3 Spacing Consistency
|
||||
|
||||
**Extension-specific spacing not implemented:**
|
||||
```css
|
||||
/* Spec defines but not used */
|
||||
--ext-space-xs: 4px;
|
||||
--ext-space-sm: 8px;
|
||||
--ext-header-height: 56px;
|
||||
--ext-quick-capture-height: 48px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Prioritized Recommendations
|
||||
|
||||
### 🔴 P0 - Critical (This Week)
|
||||
|
||||
| # | Issue | Action | Effort |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Extension backend integration | Connect to streaming API | 3 days |
|
||||
| 2 | Add ThinkingStepsDisplay to extension | Port from web | 1 day |
|
||||
| 3 | Enhance TokenInfoCard | Add price change, mcap | 0.5 day |
|
||||
| 4 | Create Welcome Screen | Add greeting + suggestions | 1 day |
|
||||
|
||||
### 🟠 P1 - High Priority (Next 2 Weeks)
|
||||
|
||||
| # | Issue | Action | Effort |
|
||||
|---|-------|--------|--------|
|
||||
| 5 | SafetyScoreDisplay component | Create new component | 2 days |
|
||||
| 6 | WatchlistPanel | Create with local storage | 3 days |
|
||||
| 7 | ChatHeader enhancement | Add space selector, user icon | 1 day |
|
||||
| 8 | ChatInput enhancement | Add attachment button | 1 day |
|
||||
| 9 | Settings Dropdown | Full menu implementation | 1 day |
|
||||
|
||||
### 🟡 P2 - Medium Priority (Weeks 3-4)
|
||||
|
||||
| # | Issue | Action | Effort |
|
||||
|---|-------|--------|--------|
|
||||
| 10 | AlertConfigModal | Create alert configuration UI | 2 days |
|
||||
| 11 | Crypto Dashboard tab (web) | New dashboard page | 3 days |
|
||||
| 12 | Watchlist Table (web) | Full watchlist management | 2 days |
|
||||
| 13 | QuickCapture enhancement | Add space selector modal | 1 day |
|
||||
| 14 | Keyboard shortcuts | Implement Cmd+K, etc. | 1 day |
|
||||
|
||||
### 🟢 P3 - Low Priority (Month 2+)
|
||||
|
||||
| # | Issue | Action | Effort |
|
||||
|---|-------|--------|--------|
|
||||
| 15 | Market Overview widget | BTC/ETH/SOL prices | 2 days |
|
||||
| 16 | Trending Tokens carousel | Hot tokens display | 2 days |
|
||||
| 17 | $TOKEN shortcuts | Chat input parsing | 1 day |
|
||||
| 18 | Design system alignment | Crypto colors, animations | 2 days |
|
||||
| 19 | Accessibility audit | ARIA, keyboard nav | 2 days |
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Component-Level Recommendations
|
||||
|
||||
### 5.1 TokenInfoCard Improvements
|
||||
|
||||
**Current:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🪙 Token Symbol │
|
||||
│ chain • address... │
|
||||
│ Price | Vol | Liquidity │
|
||||
│ [Safety] [Holders] [Predict] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Recommended:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🪙 BULLA / SOL │
|
||||
│ Solana • CA: 7xKX...3nPq [⭐] │ ← Add to watchlist
|
||||
│ │
|
||||
│ $0.00001234 ▲ +156.7% │ ← Price change indicator
|
||||
│ 24h change │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Vol 24h │ │Liquidity│ │ MCap │ │ ← Add Market Cap
|
||||
│ │ $1.2M │ │ $450K │ │ $2.1M │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ [🛡️Safety][👥Holders][📈Predict][⚠️Rug]│ ← Add Rug check
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 ChatHeader Improvements
|
||||
|
||||
**Current:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Logo] SurfSense [⚙️] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Recommended:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🌊 SurfSense [Crypto ▼] [⚙️] [👤] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 Welcome Screen Implementation
|
||||
|
||||
**Current:** Empty state with "Start a conversation..."
|
||||
|
||||
**Recommended:** Time-based greeting + suggestion cards (see wireframes in ux-design-specification.md)
|
||||
|
||||
---
|
||||
|
||||
## Part 6: User Flow Gaps
|
||||
|
||||
### 6.1 Token Safety Check Flow
|
||||
|
||||
| Step | Spec | Current | Status |
|
||||
|------|------|---------|--------|
|
||||
| 1 | User clicks Safety button | ✅ Button exists | ✅ |
|
||||
| 2 | API call to safety endpoint | ❌ Not implemented | 🔴 |
|
||||
| 3 | Loading state during analysis | ❌ Not implemented | 🔴 |
|
||||
| 4 | Display SafetyScoreDisplay | ❌ Component missing | 🔴 |
|
||||
| 5 | Add to Watchlist action | ❌ Not implemented | 🔴 |
|
||||
| 6 | Set Alert action | ❌ Not implemented | 🔴 |
|
||||
|
||||
### 6.2 Quick Capture Flow
|
||||
|
||||
| Step | Spec | Current | Status |
|
||||
|------|------|---------|--------|
|
||||
| 1 | Click capture button | ✅ Works | ✅ |
|
||||
| 2 | Select Search Space | ❌ No selector | 🟡 |
|
||||
| 3 | Show loading state | ❌ No loading UI | 🟡 |
|
||||
| 4 | Success toast | ✅ Works | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Accessibility Gaps
|
||||
|
||||
| Requirement | Status | Priority |
|
||||
|-------------|--------|----------|
|
||||
| Keyboard navigation | ❌ Missing | P2 |
|
||||
| ARIA labels | ❌ Missing | P2 |
|
||||
| Screen reader announcements | ❌ Missing | P3 |
|
||||
| Color contrast (WCAG AA) | ⚠️ Partial | P2 |
|
||||
| Focus indicators | ⚠️ Partial | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Action Items Summary
|
||||
|
||||
### Immediate Actions (This Sprint)
|
||||
|
||||
- [ ] **EXT-001**: Integrate extension with backend streaming API
|
||||
- [ ] **EXT-002**: Port ThinkingStepsDisplay to extension
|
||||
- [ ] **EXT-003**: Enhance TokenInfoCard with price change, mcap
|
||||
- [ ] **EXT-004**: Create Welcome Screen with suggestions
|
||||
- [ ] **EXT-005**: Implement SafetyScoreDisplay component
|
||||
|
||||
### Next Sprint
|
||||
|
||||
- [ ] **EXT-006**: Create WatchlistPanel component
|
||||
- [ ] **EXT-007**: Enhance ChatHeader with space selector
|
||||
- [ ] **EXT-008**: Add attachment button to ChatInput
|
||||
- [ ] **EXT-009**: Implement Settings Dropdown
|
||||
- [ ] **WEB-001**: Create Crypto Dashboard tab
|
||||
|
||||
### Backlog
|
||||
|
||||
- [ ] **EXT-010**: AlertConfigModal
|
||||
- [ ] **WEB-002**: Watchlist Table
|
||||
- [ ] **WEB-003**: Market Overview widget
|
||||
- [ ] **SYS-001**: Design system alignment
|
||||
- [ ] **ACC-001**: Accessibility audit
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File References
|
||||
|
||||
| Component | File Path | Lines |
|
||||
|-----------|-----------|-------|
|
||||
| ChatInterface | `surfsense_browser_extension/sidepanel/chat/ChatInterface.tsx` | 79 |
|
||||
| ChatHeader | `surfsense_browser_extension/sidepanel/chat/ChatHeader.tsx` | 25 |
|
||||
| ChatMessages | `surfsense_browser_extension/sidepanel/chat/ChatMessages.tsx` | 34 |
|
||||
| ChatInput | `surfsense_browser_extension/sidepanel/chat/ChatInput.tsx` | 42 |
|
||||
| TokenInfoCard | `surfsense_browser_extension/sidepanel/dexscreener/TokenInfoCard.tsx` | 83 |
|
||||
| QuickCapture | `surfsense_browser_extension/sidepanel/chat/QuickCapture.tsx` | 50 |
|
||||
| Thread (Web) | `surfsense_web/components/assistant-ui/thread.tsx` | 708 |
|
||||
| UX Spec | `_bmad-output/planning-artifacts/ux-design-specification.md` | 813 |
|
||||
| Extension UX | `_bmad-output/ux-design/extension-ux-design.md` | 933 |
|
||||
|
||||
---
|
||||
|
||||
**Report Status:** ✅ COMPLETE
|
||||
**Next Review:** After P0 items completed
|
||||
**Owner:** UX Designer
|
||||
|
||||
|
|
@ -11,7 +11,17 @@
|
|||
"pnpm": {
|
||||
"overrides": {
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@swc/core",
|
||||
"esbuild",
|
||||
"lmdb",
|
||||
"msgpackr-extract",
|
||||
"sharp"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,239 @@
|
|||
import { Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
User,
|
||||
LogOut,
|
||||
ExternalLink,
|
||||
Star,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Plug
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/routes/ui/popover";
|
||||
|
||||
export interface SearchSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
/** Available search spaces */
|
||||
searchSpaces?: SearchSpace[];
|
||||
/** Currently selected search space */
|
||||
selectedSpace?: SearchSpace;
|
||||
/** Callback when search space is changed */
|
||||
onSpaceChange?: (space: SearchSpace) => void;
|
||||
/** User display name */
|
||||
userName?: string;
|
||||
/** User avatar URL */
|
||||
userAvatar?: string;
|
||||
/** Callback when logout is clicked */
|
||||
onLogout?: () => void;
|
||||
/** Callback when settings item is clicked */
|
||||
onSettingsClick?: (item: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat header with branding and settings
|
||||
* Enhanced Chat header with branding, space selector, settings, and user menu
|
||||
*
|
||||
* Features:
|
||||
* - Search space selector dropdown
|
||||
* - Settings dropdown with full menu
|
||||
* - User avatar with logout option
|
||||
*/
|
||||
export function ChatHeader() {
|
||||
export function ChatHeader({
|
||||
searchSpaces = [],
|
||||
selectedSpace,
|
||||
onSpaceChange,
|
||||
userName,
|
||||
userAvatar,
|
||||
onLogout,
|
||||
onSettingsClick,
|
||||
}: ChatHeaderProps) {
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const defaultSpaces: SearchSpace[] = [
|
||||
{ id: "crypto", name: "Crypto", icon: "🪙" },
|
||||
{ id: "general", name: "General", icon: "📚" },
|
||||
{ id: "research", name: "Research", icon: "🔬" },
|
||||
];
|
||||
|
||||
const spaces = searchSpaces.length > 0 ? searchSpaces : defaultSpaces;
|
||||
const currentSpace = selectedSpace || spaces[0];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
{/* Logo and brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/assets/icon.png"
|
||||
alt="SurfSense"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<h1 className="font-semibold text-lg">SurfSense</h1>
|
||||
<h1 className="font-semibold text-base">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Search Space Selector */}
|
||||
<Popover open={spaceOpen} onOpenChange={setSpaceOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2"
|
||||
>
|
||||
<span>{currentSpace.icon}</span>
|
||||
<span className="max-w-[80px] truncate">{currentSpace.name}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="center">
|
||||
<div className="space-y-0.5">
|
||||
{spaces.map((space) => (
|
||||
<button
|
||||
key={space.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors",
|
||||
currentSpace.id === space.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSpaceChange?.(space);
|
||||
setSpaceOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{space.icon}</span>
|
||||
<span>{space.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Settings Dropdown */}
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="end">
|
||||
<SettingsMenu
|
||||
onItemClick={(item) => {
|
||||
onSettingsClick?.(item);
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* User Avatar */}
|
||||
<UserAvatar
|
||||
name={userName}
|
||||
avatarUrl={userAvatar}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings menu items
|
||||
*/
|
||||
function SettingsMenu({
|
||||
onItemClick,
|
||||
onLogout,
|
||||
}: {
|
||||
onItemClick?: (item: string) => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const menuItems = [
|
||||
{ id: "connectors", label: "Manage Connectors", icon: Plug },
|
||||
{ id: "chats", label: "View All Chats", icon: MessageSquare },
|
||||
{ id: "watchlist", label: "Manage Watchlist", icon: Star },
|
||||
{ id: "alerts", label: "Alert History", icon: Bell },
|
||||
{ id: "settings", label: "Full Settings", icon: Settings, external: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
onClick={() => onItemClick?.(item.id)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
{item.external && <ExternalLink className="h-3 w-3 opacity-50" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1 border-t" />
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User avatar component
|
||||
*/
|
||||
function UserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
onLogout,
|
||||
}: {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const initials = name
|
||||
? name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2)
|
||||
: "U";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/20 transition-all">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt={name || "User"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-medium text-primary">{initials}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-2" align="end">
|
||||
<div className="text-center pb-2 border-b mb-2">
|
||||
<p className="font-medium text-sm">{name || "User"}</p>
|
||||
</div>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,256 @@
|
|||
import { useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export interface AttachedFile {
|
||||
/** File ID */
|
||||
id: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File type */
|
||||
type: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** File object */
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
/** Callback when message is sent */
|
||||
onSend: (content: string, attachments?: AttachedFile[]) => void;
|
||||
/** Whether input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Whether to show attachment button */
|
||||
showAttachment?: boolean;
|
||||
/** Accepted file types */
|
||||
acceptedFileTypes?: string;
|
||||
/** Max file size in bytes (default 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Quick action suggestions */
|
||||
suggestions?: string[];
|
||||
/** Callback when suggestion is clicked */
|
||||
onSuggestionClick?: (suggestion: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat input component with send button
|
||||
* Enhanced chat input with attachment support and suggestions
|
||||
*
|
||||
* Features:
|
||||
* - Text input with send button
|
||||
* - File attachment button
|
||||
* - Attached files preview
|
||||
* - Quick action suggestions
|
||||
* - Keyboard shortcuts (Enter to send)
|
||||
*/
|
||||
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
showAttachment = true,
|
||||
acceptedFileTypes = ".pdf,.txt,.md,.json,.csv,image/*",
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
suggestions = [],
|
||||
onSuggestionClick,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !disabled) {
|
||||
onSend(input.trim());
|
||||
if ((input.trim() || attachments.length > 0) && !disabled) {
|
||||
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newAttachments: AttachedFile[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
console.warn(`File ${file.name} exceeds max size of ${maxFileSize / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
newAttachments.push({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
});
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith("image/")) return Image;
|
||||
if (type.includes("pdf") || type.includes("text")) return FileText;
|
||||
return File;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={disabled || !input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="border-t">
|
||||
{/* Quick suggestions */}
|
||||
{suggestions.length > 0 && input.length === 0 && attachments.length === 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{suggestions.slice(0, 3).map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 rounded-full bg-muted hover:bg-muted/80 text-muted-foreground transition-colors"
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.type);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-sm group"
|
||||
>
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatFileSize(attachment.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
className="ml-1 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
dragOver && "bg-primary/5"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
{showAttachment && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedFileTypes}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md bg-background text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"resize-none min-h-[38px] max-h-[120px]",
|
||||
"scrollbar-thin scrollbar-thumb-muted"
|
||||
)}
|
||||
style={{
|
||||
height: "auto",
|
||||
minHeight: "38px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={disabled || (!input.trim() && attachments.length === 0)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-md border-2 border-dashed border-primary pointer-events-none">
|
||||
<p className="text-sm text-primary font-medium">Drop files here</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,78 +2,472 @@ import { useState } from "react";
|
|||
import { usePageContext } from "../context/PageContextProvider";
|
||||
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
|
||||
import { QuickCapture } from "./QuickCapture";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatHeader, type SearchSpace } from "./ChatHeader";
|
||||
import { ChatMessages, type Message, type MessageWidget } from "./ChatMessages";
|
||||
import { ChatInput, type AttachedFile } from "./ChatInput";
|
||||
import { ThinkingStepsDisplay, type ThinkingStep } from "./ThinkingStepsDisplay";
|
||||
import {
|
||||
MOCK_MODE,
|
||||
MOCK_SEARCH_SPACES,
|
||||
MOCK_WATCHLIST_TOKENS,
|
||||
MOCK_WATCHLIST_ALERTS,
|
||||
MOCK_SAFETY_SCORE,
|
||||
MOCK_SAFETY_FACTORS,
|
||||
MOCK_SAFETY_SOURCES,
|
||||
} from "../mock/mockData";
|
||||
import { SafetyScoreDisplay } from "../crypto/SafetyScoreDisplay";
|
||||
import { WatchlistPanel } from "../crypto/WatchlistPanel";
|
||||
import { AlertConfigModal } from "../crypto/AlertConfigModal";
|
||||
import type { WatchlistItem } from "../widgets";
|
||||
|
||||
type ViewMode = "chat" | "watchlist" | "safety";
|
||||
|
||||
/**
|
||||
* Natural language command patterns for conversational UX
|
||||
*/
|
||||
const COMMAND_PATTERNS = {
|
||||
ADD_WATCHLIST: /add\s+(\w+)\s+to\s+(my\s+)?watchlist/i,
|
||||
REMOVE_WATCHLIST: /remove\s+(\w+)\s+from\s+(my\s+)?watchlist/i,
|
||||
SHOW_WATCHLIST: /(show|display|view)\s+(my\s+)?watchlist/i,
|
||||
SET_ALERT: /set\s+alert\s+(if|when)\s+(\w+)\s+(drops?|pumps?|reaches?|changes?)\s+(\d+)%?/i,
|
||||
ANALYZE_TOKEN: /(analyze|research|check)\s+(\w+)/i,
|
||||
SAFETY_CHECK: /(is\s+)?(\w+)\s+(safe|risky|rug)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main chat interface for side panel
|
||||
* Adapts UI based on page context (e.g., shows token card on DexScreener)
|
||||
*
|
||||
* Features:
|
||||
* - Context-aware UI (DexScreener token detection)
|
||||
* - Welcome screen for new users
|
||||
* - Thinking steps visualization
|
||||
* - File attachments support
|
||||
* - Search space selection
|
||||
* - Watchlist panel
|
||||
* - Safety analysis view
|
||||
*/
|
||||
export function ChatInterface() {
|
||||
const { context } = usePageContext();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const { context, isMockMode } = usePageContext();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
|
||||
const [selectedSpace, setSelectedSpace] = useState<SearchSpace>(
|
||||
MOCK_SEARCH_SPACES[0]
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("chat");
|
||||
const [showAlertModal, setShowAlertModal] = useState(false);
|
||||
const [selectedTokenForAlert, setSelectedTokenForAlert] = useState<string | null>(null);
|
||||
const [watchlistTokens, setWatchlistTokens] = useState(MOCK_WATCHLIST_TOKENS);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
// TODO: Implement message sending with backend API
|
||||
console.log("Sending message:", content);
|
||||
// Mock user data - in production, this would come from auth context
|
||||
const userName = "Crypto Trader";
|
||||
|
||||
const handleSendMessage = async (content: string, attachments?: AttachedFile[]) => {
|
||||
console.log("Sending message:", content, attachments);
|
||||
setIsStreaming(true);
|
||||
setViewMode("chat");
|
||||
|
||||
// Add user message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
const userMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Simulate thinking steps
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isActive: true },
|
||||
]);
|
||||
|
||||
// TODO: Stream response from backend
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "This is a placeholder response. Backend integration coming soon!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isActive: true },
|
||||
]);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isComplete: true },
|
||||
{ id: "3", type: "analyzing", title: "Analyzing results...", isActive: true },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
}, 1000);
|
||||
|
||||
// Generate response based on content - with embedded widgets
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([]);
|
||||
|
||||
let responseContent = "";
|
||||
let widget: MessageWidget | undefined;
|
||||
const tokenSymbol = context?.tokenData?.tokenSymbol || "BULLA";
|
||||
|
||||
// Check for natural language commands
|
||||
const addWatchlistMatch = content.match(COMMAND_PATTERNS.ADD_WATCHLIST);
|
||||
const showWatchlistMatch = content.match(COMMAND_PATTERNS.SHOW_WATCHLIST);
|
||||
const setAlertMatch = content.match(COMMAND_PATTERNS.SET_ALERT);
|
||||
|
||||
if (addWatchlistMatch || content.toLowerCase().includes("add") && content.toLowerCase().includes("watchlist")) {
|
||||
// Add to watchlist command
|
||||
const token = addWatchlistMatch?.[1] || tokenSymbol;
|
||||
responseContent = `Done! ✅\n\nI've added ${token} to your watchlist.`;
|
||||
widget = {
|
||||
type: "action_confirmation",
|
||||
actionType: "watchlist_add",
|
||||
tokenSymbol: token,
|
||||
details: [
|
||||
"Price change ±20%",
|
||||
"Liquidity drop >10%",
|
||||
"Whale movement >$50K",
|
||||
],
|
||||
};
|
||||
// Actually add to watchlist
|
||||
if (!watchlistTokens.find(t => t.symbol === token)) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: token,
|
||||
name: token + " Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
contractAddress: context?.tokenData?.pairAddress || "unknown",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
setIsInWatchlist(true);
|
||||
}
|
||||
} else if (showWatchlistMatch || content.toLowerCase().includes("watchlist") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view"))) {
|
||||
// Show watchlist command
|
||||
responseContent = `Here's your watchlist:`;
|
||||
const watchlistItems: WatchlistItem[] = watchlistTokens.map(t => ({
|
||||
id: t.id,
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
chain: t.chain,
|
||||
price: t.price,
|
||||
priceChange24h: t.priceChange24h,
|
||||
alertCount: t.alertCount,
|
||||
}));
|
||||
widget = {
|
||||
type: "watchlist",
|
||||
tokens: watchlistItems,
|
||||
};
|
||||
if (watchlistTokens.length > 0) {
|
||||
const bestPerformer = watchlistTokens.reduce((a, b) =>
|
||||
a.priceChange24h > b.priceChange24h ? a : b
|
||||
);
|
||||
responseContent += `\n\n${bestPerformer.symbol} is up ${bestPerformer.priceChange24h.toFixed(1)}% - your best performer! Want me to analyze if it's time to take profits?`;
|
||||
}
|
||||
} else if (setAlertMatch || content.toLowerCase().includes("alert") && (content.toLowerCase().includes("set") || content.toLowerCase().includes("notify"))) {
|
||||
// Set alert command
|
||||
const match = content.match(/(\d+)%/);
|
||||
const percentage = match ? match[1] : "20";
|
||||
const direction = content.toLowerCase().includes("drop") ? "drops" : "changes";
|
||||
responseContent = `I'll set that up for you:`;
|
||||
widget = {
|
||||
type: "alert_config",
|
||||
config: {
|
||||
tokenSymbol: tokenSymbol,
|
||||
condition: `Price ${direction} ${percentage}%`,
|
||||
currentPrice: context?.tokenData?.price || "$0.00001234",
|
||||
triggerPrice: "$0.00000987",
|
||||
channels: {
|
||||
browser: true,
|
||||
inApp: true,
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
isNew: true,
|
||||
};
|
||||
responseContent += `\n\nDone! I'll notify you if ${tokenSymbol} ${direction} ${percentage}% from current price. Want to set any other alerts?`;
|
||||
} else if (content.toLowerCase().includes("safe") || content.toLowerCase().includes("rug") || content.toLowerCase().includes("analyze") || content.toLowerCase().includes("research")) {
|
||||
// Token analysis with embedded widget
|
||||
responseContent = `Here's my analysis of ${tokenSymbol}:`;
|
||||
widget = {
|
||||
type: "token_analysis",
|
||||
data: {
|
||||
symbol: tokenSymbol,
|
||||
name: context?.tokenData?.tokenName || "Bulla Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
marketCap: "$2.1M",
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
safetyScore: MOCK_SAFETY_SCORE,
|
||||
holderCount: 12456,
|
||||
top10HolderPercent: 35,
|
||||
},
|
||||
isInWatchlist: isInWatchlist,
|
||||
};
|
||||
responseContent += `\n\nBased on your moderate risk profile, suggested allocation: 2-5% of portfolio. The safety score of ${MOCK_SAFETY_SCORE}/100 indicates medium risk - proceed with caution.`;
|
||||
} else if (content.toLowerCase().includes("holder")) {
|
||||
responseContent = `**Holder Analysis for ${tokenSymbol}:**
|
||||
|
||||
📊 **Distribution:**
|
||||
- Total Holders: 12,456
|
||||
- Top 10 Holders: 35% of supply
|
||||
- Top 50 Holders: 52% of supply
|
||||
|
||||
🐋 **Whale Activity (24h):**
|
||||
- 3 large buys (>$10K each)
|
||||
- 1 large sell ($25K)
|
||||
- Net flow: +$15K
|
||||
|
||||
⚠️ **Concentration Risk:** Medium
|
||||
The top holder owns 8.5% which is relatively high.`;
|
||||
} else {
|
||||
responseContent = `I can help you with crypto analysis! Try these commands:
|
||||
|
||||
• **"Add BULLA to my watchlist"** - Track tokens
|
||||
• **"Show my watchlist"** - View tracked tokens
|
||||
• **"Set alert if BULLA drops 20%"** - Price alerts
|
||||
• **"Analyze BULLA"** - Full token analysis
|
||||
• **"Is BULLA safe?"** - Safety check
|
||||
|
||||
What would you like to know?`;
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: responseContent,
|
||||
timestamp: new Date(),
|
||||
widget,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsStreaming(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (text: string) => {
|
||||
handleSendMessage(text);
|
||||
};
|
||||
|
||||
const handleSpaceChange = (space: SearchSpace) => {
|
||||
setSelectedSpace(space);
|
||||
};
|
||||
|
||||
const handleSettingsClick = (item: string) => {
|
||||
console.log("Settings item clicked:", item);
|
||||
if (item === "watchlist") {
|
||||
setViewMode("watchlist");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log("Logout clicked");
|
||||
};
|
||||
|
||||
const handleSafetyCheck = () => {
|
||||
setViewMode("safety");
|
||||
};
|
||||
|
||||
const handleAddToWatchlist = () => {
|
||||
setIsInWatchlist(!isInWatchlist);
|
||||
if (!isInWatchlist && context?.tokenData) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: context.tokenData.tokenSymbol || "TOKEN",
|
||||
name: context.tokenData.tokenName || "Unknown Token",
|
||||
chain: context.tokenData.chain,
|
||||
contractAddress: context.tokenData.pairAddress,
|
||||
price: context.tokenData.price || "$0",
|
||||
priceChange24h: context.tokenData.priceChange24h || 0,
|
||||
hasAlerts: false,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureAlerts = (tokenSymbol: string) => {
|
||||
setSelectedTokenForAlert(tokenSymbol);
|
||||
setShowAlertModal(true);
|
||||
};
|
||||
|
||||
const handleRugCheck = () => {
|
||||
handleSendMessage("Check this token for rug pull risks");
|
||||
};
|
||||
|
||||
// Handle widget actions from embedded widgets in chat
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
console.log("Widget action:", action, data);
|
||||
switch (action) {
|
||||
case "view_watchlist":
|
||||
handleSendMessage("Show my watchlist");
|
||||
break;
|
||||
case "edit_alerts":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_token":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Analyze ${(data as { symbol: string }).symbol}`);
|
||||
}
|
||||
break;
|
||||
case "remove_from_watchlist":
|
||||
if (typeof data === "string") {
|
||||
setWatchlistTokens(prev => prev.filter(t => t.id !== data));
|
||||
}
|
||||
break;
|
||||
case "add_to_watchlist":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Add ${(data as { symbol: string }).symbol} to my watchlist`);
|
||||
}
|
||||
break;
|
||||
case "set_alert":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_further":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { symbol: string }).symbol} holders and whale activity`);
|
||||
}
|
||||
break;
|
||||
case "tell_more":
|
||||
if (data && typeof data === "object" && "tokenSymbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { tokenSymbol: string }).tokenSymbol}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log("Unhandled widget action:", action);
|
||||
}
|
||||
};
|
||||
|
||||
// Quick suggestions based on context
|
||||
const quickSuggestions = context?.pageType === "dexscreener"
|
||||
? ["Add to watchlist", "Is this safe?", "Set price alert"]
|
||||
: ["Show my watchlist", "What's trending?", "Analyze BULLA"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<ChatHeader />
|
||||
{/* Header with space selector and settings */}
|
||||
<ChatHeader
|
||||
searchSpaces={MOCK_SEARCH_SPACES}
|
||||
selectedSpace={selectedSpace}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
userName={userName}
|
||||
onSettingsClick={handleSettingsClick}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
{/* Token info card (only on DexScreener) */}
|
||||
{context?.pageType === "dexscreener" && context.tokenData && (
|
||||
<TokenInfoCard tokenData={context.tokenData} />
|
||||
{context?.pageType === "dexscreener" && context.tokenData && viewMode === "chat" && (
|
||||
<TokenInfoCard
|
||||
tokenData={context.tokenData}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSafetyCheck={handleSafetyCheck}
|
||||
onRugCheck={handleRugCheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChatMessages messages={messages} />
|
||||
{viewMode === "chat" && (
|
||||
<>
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
userName={userName}
|
||||
onWidgetAction={handleWidgetAction}
|
||||
/>
|
||||
|
||||
{/* Thinking steps (shown during streaming) */}
|
||||
{isStreaming && thinkingSteps.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<ThinkingStepsDisplay
|
||||
steps={thinkingSteps}
|
||||
isThinking={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === "watchlist" && (
|
||||
<WatchlistPanel
|
||||
tokens={watchlistTokens}
|
||||
recentAlerts={MOCK_WATCHLIST_ALERTS}
|
||||
onTokenClick={(token) => console.log("Token clicked:", token)}
|
||||
onRemoveToken={(id) => setWatchlistTokens(prev => prev.filter(t => t.id !== id))}
|
||||
onAddToken={() => console.log("Add token clicked")}
|
||||
onConfigureAlerts={(token) => handleConfigureAlerts(token.symbol)}
|
||||
onAlertClick={(alert) => console.log("Alert clicked:", alert)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === "safety" && (
|
||||
<div className="p-4">
|
||||
<SafetyScoreDisplay
|
||||
score={MOCK_SAFETY_SCORE}
|
||||
factors={MOCK_SAFETY_FACTORS}
|
||||
sources={MOCK_SAFETY_SOURCES}
|
||||
timestamp={new Date()}
|
||||
tokenSymbol={context?.tokenData?.tokenSymbol}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSetAlert={() => handleConfigureAlerts(context?.tokenData?.tokenSymbol || "TOKEN")}
|
||||
/>
|
||||
<button
|
||||
className="mt-4 text-sm text-primary hover:underline"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat input */}
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
placeholder={
|
||||
context?.pageType === "dexscreener"
|
||||
? "Ask about this token..."
|
||||
: "Ask me anything..."
|
||||
}
|
||||
/>
|
||||
{/* Chat input (only in chat mode) */}
|
||||
{viewMode === "chat" && (
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
placeholder={
|
||||
context?.pageType === "dexscreener"
|
||||
? `Ask about ${context.tokenData?.tokenSymbol || "this token"}...`
|
||||
: "Ask me anything..."
|
||||
}
|
||||
suggestions={messages.length === 0 ? [] : quickSuggestions}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back to chat button for other views */}
|
||||
{viewMode !== "chat" && (
|
||||
<div className="border-t p-3">
|
||||
<button
|
||||
className="w-full py-2 text-sm text-center text-primary hover:bg-primary/5 rounded-md transition-colors"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick capture button */}
|
||||
<QuickCapture />
|
||||
|
||||
{/* Alert configuration modal */}
|
||||
<AlertConfigModal
|
||||
open={showAlertModal}
|
||||
onOpenChange={setShowAlertModal}
|
||||
tokenSymbol={selectedTokenForAlert || "TOKEN"}
|
||||
currentPrice={context?.tokenData?.price}
|
||||
onSave={(alerts) => {
|
||||
console.log("Alerts saved:", alerts);
|
||||
setShowAlertModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,169 @@
|
|||
import { WelcomeScreen } from "./WelcomeScreen";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ActionConfirmationWidget,
|
||||
ProactiveAlertCard,
|
||||
WatchlistWidget,
|
||||
AlertWidget,
|
||||
TokenAnalysisWidget,
|
||||
type ProactiveAlertData,
|
||||
type WatchlistItem,
|
||||
type AlertConfigData,
|
||||
type TokenAnalysisData,
|
||||
} from "../widgets";
|
||||
|
||||
// Widget types that can be embedded in messages
|
||||
export type MessageWidget =
|
||||
| { type: "action_confirmation"; actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete"; tokenSymbol: string; details?: string[] }
|
||||
| { type: "proactive_alert"; alert: ProactiveAlertData; recommendation?: string }
|
||||
| { type: "watchlist"; tokens: WatchlistItem[] }
|
||||
| { type: "alert_config"; config: AlertConfigData; isNew?: boolean }
|
||||
| { type: "token_analysis"; data: TokenAnalysisData; isInWatchlist?: boolean };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
isStreaming?: boolean;
|
||||
/** Embedded widget to display with this message */
|
||||
widget?: MessageWidget;
|
||||
}
|
||||
|
||||
export interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
userName?: string;
|
||||
/** Callbacks for widget interactions */
|
||||
onWidgetAction?: (action: string, data?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat messages display component
|
||||
* Chat messages display component with embedded widget support
|
||||
* Shows WelcomeScreen when no messages, otherwise displays conversation
|
||||
*
|
||||
* Supports embedded widgets for conversational UX:
|
||||
* - ActionConfirmationWidget: Shows action confirmations
|
||||
* - ProactiveAlertCard: AI-initiated alerts
|
||||
* - WatchlistWidget: Inline watchlist display
|
||||
* - AlertWidget: Alert configuration display
|
||||
* - TokenAnalysisWidget: Full token analysis
|
||||
*/
|
||||
export function ChatMessages({ messages }: { messages: any[] }) {
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
onSuggestionClick,
|
||||
userName,
|
||||
onWidgetAction,
|
||||
}: ChatMessagesProps) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p>Start a conversation...</p>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
userName={userName}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
onWidgetAction?.(action, data);
|
||||
};
|
||||
|
||||
const renderWidget = (widget: MessageWidget) => {
|
||||
switch (widget.type) {
|
||||
case "action_confirmation":
|
||||
return (
|
||||
<ActionConfirmationWidget
|
||||
actionType={widget.actionType}
|
||||
tokenSymbol={widget.tokenSymbol}
|
||||
details={widget.details}
|
||||
onViewWatchlist={() => handleWidgetAction("view_watchlist")}
|
||||
onEditAlerts={() => handleWidgetAction("edit_alerts", widget.tokenSymbol)}
|
||||
/>
|
||||
);
|
||||
case "proactive_alert":
|
||||
return (
|
||||
<ProactiveAlertCard
|
||||
alert={widget.alert}
|
||||
recommendation={widget.recommendation}
|
||||
onViewDetails={() => handleWidgetAction("view_alert_details", widget.alert)}
|
||||
onDismiss={() => handleWidgetAction("dismiss_alert", widget.alert.id)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.alert.tokenSymbol)}
|
||||
onTellMore={() => handleWidgetAction("tell_more", widget.alert)}
|
||||
/>
|
||||
);
|
||||
case "watchlist":
|
||||
return (
|
||||
<WatchlistWidget
|
||||
tokens={widget.tokens}
|
||||
onAnalyze={(token) => handleWidgetAction("analyze_token", token)}
|
||||
onRemove={(id) => handleWidgetAction("remove_from_watchlist", id)}
|
||||
onAddToken={() => handleWidgetAction("add_token")}
|
||||
onClearAll={() => handleWidgetAction("clear_watchlist")}
|
||||
/>
|
||||
);
|
||||
case "alert_config":
|
||||
return (
|
||||
<AlertWidget
|
||||
config={widget.config}
|
||||
isNew={widget.isNew}
|
||||
onEdit={() => handleWidgetAction("edit_alert", widget.config)}
|
||||
onDelete={() => handleWidgetAction("delete_alert", widget.config)}
|
||||
onAddAnother={() => handleWidgetAction("add_another_alert")}
|
||||
onViewAll={() => handleWidgetAction("view_all_alerts")}
|
||||
/>
|
||||
);
|
||||
case "token_analysis":
|
||||
return (
|
||||
<TokenAnalysisWidget
|
||||
data={widget.data}
|
||||
isInWatchlist={widget.isInWatchlist}
|
||||
onAddToWatchlist={() => handleWidgetAction("add_to_watchlist", widget.data)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.data.symbol)}
|
||||
onAnalyzeFurther={() => handleWidgetAction("analyze_further", widget.data)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
message.role === "user" ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{/* Message bubble */}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${message.role === "user"
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg p-3",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
: "bg-muted",
|
||||
message.isStreaming && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
{message.timestamp && (
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embedded widget (for assistant messages) */}
|
||||
{message.role === "assistant" && message.widget && (
|
||||
<div className="w-full max-w-[95%] mt-2">
|
||||
{renderWidget(message.widget)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal file
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { MOCK_TOKEN_DATA, MOCK_MODE } from "../mock/mockData";
|
||||
|
||||
/**
|
||||
* Page context types
|
||||
|
|
@ -9,9 +10,12 @@ export interface TokenData {
|
|||
chain: string;
|
||||
pairAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
price?: string;
|
||||
priceChange24h?: number;
|
||||
volume24h?: string;
|
||||
liquidity?: string;
|
||||
marketCap?: string;
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
|
|
@ -24,11 +28,14 @@ export interface PageContext {
|
|||
interface PageContextValue {
|
||||
context: PageContext | null;
|
||||
updateContext: (context: PageContext) => void;
|
||||
/** Whether we're using mock data */
|
||||
isMockMode: boolean;
|
||||
}
|
||||
|
||||
const PageContextContext = createContext<PageContextValue>({
|
||||
context: null,
|
||||
updateContext: () => { },
|
||||
isMockMode: false,
|
||||
});
|
||||
|
||||
export function usePageContext() {
|
||||
|
|
@ -38,11 +45,24 @@ export function usePageContext() {
|
|||
/**
|
||||
* Provider for page context detection
|
||||
* Listens to messages from content scripts
|
||||
* Uses mock data in development mode
|
||||
*/
|
||||
export function PageContextProvider({ children }: { children: ReactNode }) {
|
||||
const [context, setContext] = useState<PageContext | null>(null);
|
||||
const isMockMode = MOCK_MODE.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
// Use mock data in development mode
|
||||
if (MOCK_MODE.enabled && MOCK_MODE.simulateDexScreener) {
|
||||
setContext({
|
||||
url: "https://dexscreener.com/solana/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
title: "BULLA / SOL | DEX Screener",
|
||||
pageType: "dexscreener",
|
||||
tokenData: MOCK_TOKEN_DATA,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for page context updates from content script
|
||||
const handleMessage = (message: any) => {
|
||||
if (message.type === "PAGE_CONTEXT_UPDATE") {
|
||||
|
|
@ -65,7 +85,7 @@ export function PageContextProvider({ children }: { children: ReactNode }) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<PageContextContext.Provider value={{ context, updateContext: setContext }}>
|
||||
<PageContextContext.Provider value={{ context, updateContext: setContext, isMockMode }}>
|
||||
{children}
|
||||
</PageContextContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
||||
322
surfsense_browser_extension/sidepanel/crypto/WatchlistPanel.tsx
Normal file
322
surfsense_browser_extension/sidepanel/crypto/WatchlistPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
6
surfsense_browser_extension/sidepanel/crypto/index.ts
Normal file
6
surfsense_browser_extension/sidepanel/crypto/index.ts
Normal 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";
|
||||
|
||||
|
|
@ -1,59 +1,190 @@
|
|||
import { useState } from "react";
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { TrendingUp, Shield, Users } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Shield,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Copy,
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export type QuickActionType = "safety" | "holders" | "predict" | "rug";
|
||||
|
||||
export interface EnhancedTokenData extends TokenData {
|
||||
priceChange24h?: number;
|
||||
marketCap?: string;
|
||||
}
|
||||
|
||||
interface TokenInfoCardProps {
|
||||
tokenData: TokenData;
|
||||
tokenData: EnhancedTokenData;
|
||||
/** Whether token is in user's watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
/** Callback when quick action button is clicked (generic handler) */
|
||||
onQuickAction?: (action: QuickActionType, tokenData: EnhancedTokenData) => void;
|
||||
/** Callback when watchlist button is clicked */
|
||||
onToggleWatchlist?: (tokenData: EnhancedTokenData) => void;
|
||||
/** Alternative: Direct callback for add to watchlist */
|
||||
onAddToWatchlist?: () => void;
|
||||
/** Alternative: Direct callback for safety check */
|
||||
onSafetyCheck?: () => void;
|
||||
/** Alternative: Direct callback for rug check */
|
||||
onRugCheck?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token info card displayed when viewing DexScreener
|
||||
* Shows quick token stats and action buttons
|
||||
* TokenInfoCard - Enhanced token info card for DexScreener pages
|
||||
*
|
||||
* Features:
|
||||
* - Price with 24h change indicator (▲/▼)
|
||||
* - Market cap display
|
||||
* - Add to watchlist button
|
||||
* - 4 quick actions: Safety, Holders, Predict, Rug Check
|
||||
* - Copy contract address
|
||||
* - Chain-specific icon
|
||||
*/
|
||||
export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
|
||||
const handleQuickAction = (action: string) => {
|
||||
// TODO: Implement quick actions
|
||||
console.log("Quick action:", action, tokenData);
|
||||
export function TokenInfoCard({
|
||||
tokenData,
|
||||
isInWatchlist = false,
|
||||
onQuickAction,
|
||||
onToggleWatchlist,
|
||||
onAddToWatchlist,
|
||||
onSafetyCheck,
|
||||
onRugCheck,
|
||||
}: TokenInfoCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleQuickAction = (action: QuickActionType) => {
|
||||
// Use specific callbacks if provided, otherwise fall back to generic handler
|
||||
if (action === "safety" && onSafetyCheck) {
|
||||
onSafetyCheck();
|
||||
} else if (action === "rug" && onRugCheck) {
|
||||
onRugCheck();
|
||||
} else if (onQuickAction) {
|
||||
onQuickAction(action, tokenData);
|
||||
} else {
|
||||
console.log("Quick action:", action, tokenData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenData.pairAddress);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy address:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWatchlist = () => {
|
||||
// Use specific callback if provided, otherwise fall back to generic handler
|
||||
if (onAddToWatchlist) {
|
||||
onAddToWatchlist();
|
||||
} else if (onToggleWatchlist) {
|
||||
onToggleWatchlist(tokenData);
|
||||
}
|
||||
};
|
||||
|
||||
const priceChange = tokenData.priceChange24h;
|
||||
const isPositive = priceChange !== undefined && priceChange > 0;
|
||||
const isNegative = priceChange !== undefined && priceChange < 0;
|
||||
|
||||
return (
|
||||
<div className="border-b p-4 bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{/* Header with token info and watchlist */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">🪙</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">
|
||||
{tokenData.tokenSymbol || "Unknown Token"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tokenData.chain} • {tokenData.pairAddress.slice(0, 8)}...
|
||||
</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{tokenData.tokenSymbol || "Unknown Token"}
|
||||
</h3>
|
||||
{/* Watchlist button */}
|
||||
<button
|
||||
onClick={handleToggleWatchlist}
|
||||
className={cn(
|
||||
"p-1 rounded-full transition-colors",
|
||||
isInWatchlist
|
||||
? "text-yellow-500 hover:text-yellow-600"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
title={isInWatchlist ? "Remove from watchlist" : "Add to watchlist"}
|
||||
>
|
||||
<Star className={cn("h-4 w-4", isInWatchlist && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ChainIcon chain={tokenData.chain} size="sm" />
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={handleCopyAddress}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
title="Copy contract address"
|
||||
>
|
||||
<span>{tokenData.pairAddress.slice(0, 6)}...{tokenData.pairAddress.slice(-4)}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token stats */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">Price</span>
|
||||
<p className="font-medium">{tokenData.price || "—"}</p>
|
||||
</div>
|
||||
{/* Price with change indicator */}
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold">{tokenData.price || "—"}</span>
|
||||
{priceChange !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{isPositive ? "+" : ""}{priceChange.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token stats grid - now with 4 columns including market cap */}
|
||||
<div className="grid grid-cols-4 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">24h Vol</span>
|
||||
<p className="font-medium">{tokenData.volume24h || "—"}</p>
|
||||
<p className="font-medium truncate">{tokenData.volume24h || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">Liquidity</span>
|
||||
<p className="font-medium">{tokenData.liquidity || "—"}</p>
|
||||
<p className="font-medium truncate">{tokenData.liquidity || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">MCap</span>
|
||||
<p className="font-medium truncate">{tokenData.marketCap || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">Chain</span>
|
||||
<p className="font-medium capitalize truncate">{tokenData.chain}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{/* Quick actions - now with 4 buttons */}
|
||||
<div className="grid grid-cols-4 gap-1.5 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => handleQuickAction("safety")}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
|
|
@ -62,7 +193,7 @@ export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => handleQuickAction("holders")}
|
||||
>
|
||||
<Users className="mr-1 h-3 w-3" />
|
||||
|
|
@ -71,12 +202,21 @@ export function TokenInfoCard({ tokenData }: TokenInfoCardProps) {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => handleQuickAction("prediction")}
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => handleQuickAction("predict")}
|
||||
>
|
||||
<TrendingUp className="mr-1 h-3 w-3" />
|
||||
Predict
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950"
|
||||
onClick={() => handleQuickAction("rug")}
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Rug
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
280
surfsense_browser_extension/sidepanel/mock/mockData.ts
Normal file
280
surfsense_browser_extension/sidepanel/mock/mockData.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Mock data for testing SurfSense Extension UI
|
||||
* Remove or disable in production
|
||||
*/
|
||||
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
import type { WatchlistToken, WatchlistAlert } from "../crypto/WatchlistPanel";
|
||||
import type { SafetyFactor } from "../crypto/SafetyScoreDisplay";
|
||||
import type { AlertConfig } from "../crypto/AlertConfigModal";
|
||||
|
||||
// ============================================
|
||||
// MOCK TOKEN DATA (DexScreener)
|
||||
// ============================================
|
||||
|
||||
export const MOCK_TOKEN_DATA: TokenData & {
|
||||
priceChange24h: number;
|
||||
marketCap: string;
|
||||
tokenName: string;
|
||||
} = {
|
||||
chain: "solana",
|
||||
pairAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
price: "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
marketCap: "$2.1M",
|
||||
};
|
||||
|
||||
export const MOCK_TOKEN_DATA_BEARISH: TokenData & {
|
||||
priceChange24h: number;
|
||||
marketCap: string;
|
||||
tokenName: string;
|
||||
} = {
|
||||
chain: "ethereum",
|
||||
pairAddress: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
tokenSymbol: "REKT",
|
||||
tokenName: "Rekt Token",
|
||||
price: "$0.00000042",
|
||||
priceChange24h: -78.5,
|
||||
volume24h: "$89K",
|
||||
liquidity: "$12K",
|
||||
marketCap: "$156K",
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MOCK SAFETY ANALYSIS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_SAFETY_SCORE = 72;
|
||||
|
||||
export const MOCK_SAFETY_FACTORS: SafetyFactor[] = [
|
||||
// Positive factors
|
||||
{
|
||||
category: "Liquidity",
|
||||
status: "positive",
|
||||
description: "Liquidity pool is locked for 12 months",
|
||||
},
|
||||
{
|
||||
category: "Contract",
|
||||
status: "positive",
|
||||
description: "Contract is verified on Solscan",
|
||||
},
|
||||
{
|
||||
category: "Age",
|
||||
status: "positive",
|
||||
description: "Token has been active for 45 days",
|
||||
},
|
||||
// Warning factors
|
||||
{
|
||||
category: "Holders",
|
||||
status: "warning",
|
||||
description: "Top 10 holders own 35% of supply",
|
||||
},
|
||||
{
|
||||
category: "Volume",
|
||||
status: "warning",
|
||||
description: "Trading volume decreased 40% in last 24h",
|
||||
},
|
||||
// Danger factors
|
||||
{
|
||||
category: "Mint Authority",
|
||||
status: "danger",
|
||||
description: "Mint authority is NOT revoked - tokens can be minted",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_SAFETY_SOURCES = [
|
||||
"RugCheck.xyz",
|
||||
"GoPlus Security",
|
||||
"Solscan",
|
||||
"DexScreener",
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK WATCHLIST
|
||||
// ============================================
|
||||
|
||||
export const MOCK_WATCHLIST_TOKENS: WatchlistToken[] = [
|
||||
{
|
||||
id: "1",
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
price: "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
hasAlerts: true,
|
||||
alertCount: 2,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
contractAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
||||
price: "$0.00002156",
|
||||
priceChange24h: 12.3,
|
||||
hasAlerts: true,
|
||||
alertCount: 1,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
contractAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
price: "$2.45",
|
||||
priceChange24h: -5.2,
|
||||
hasAlerts: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
price: "$0.00001089",
|
||||
priceChange24h: 8.7,
|
||||
hasAlerts: false,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
contractAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
|
||||
price: "$0.0156",
|
||||
priceChange24h: -15.3,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_WATCHLIST_ALERTS: WatchlistAlert[] = [
|
||||
{
|
||||
id: "alert-1",
|
||||
tokenSymbol: "BULLA",
|
||||
type: "price",
|
||||
message: "BULLA price increased above $0.00001 (+156%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 mins ago
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: "alert-2",
|
||||
tokenSymbol: "BULLA",
|
||||
type: "whale",
|
||||
message: "Large transaction detected: 500M BULLA ($6,170) transferred",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 mins ago
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: "alert-3",
|
||||
tokenSymbol: "DEGEN",
|
||||
type: "volume",
|
||||
message: "DEGEN volume spike: 5x average in last hour",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 mins ago
|
||||
isRead: true,
|
||||
},
|
||||
{
|
||||
id: "alert-4",
|
||||
tokenSymbol: "BONK",
|
||||
type: "liquidity",
|
||||
message: "BONK liquidity increased by 25% ($1.2M added)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
|
||||
isRead: true,
|
||||
},
|
||||
{
|
||||
id: "alert-5",
|
||||
tokenSymbol: "DEGEN",
|
||||
type: "price",
|
||||
message: "DEGEN dropped below $0.02 (-15%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 120), // 2 hours ago
|
||||
isRead: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK ALERT CONFIGS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_ALERT_CONFIGS: AlertConfig[] = [
|
||||
{ type: "price_above", threshold: 0.00002, enabled: true },
|
||||
{ type: "price_below", threshold: 0.000005, enabled: true },
|
||||
{ type: "price_change", threshold: 20, enabled: false },
|
||||
{ type: "volume", threshold: 3, enabled: true },
|
||||
{ type: "whale", threshold: 10000, enabled: false },
|
||||
{ type: "liquidity", threshold: 30, enabled: false },
|
||||
{ type: "holder_concentration", threshold: 50, enabled: false },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK SEARCH SPACES
|
||||
// ============================================
|
||||
|
||||
export const MOCK_SEARCH_SPACES = [
|
||||
{ id: "crypto", name: "Crypto", icon: "🪙" },
|
||||
{ id: "general", name: "General", icon: "📚" },
|
||||
{ id: "research", name: "Research", icon: "🔬" },
|
||||
{ id: "defi", name: "DeFi", icon: "💰" },
|
||||
{ id: "nft", name: "NFT", icon: "🖼️" },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK CHAT MESSAGES
|
||||
// ============================================
|
||||
|
||||
export const MOCK_CHAT_MESSAGES = [
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user" as const,
|
||||
content: "Is BULLA token safe to invest?",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "assistant" as const,
|
||||
content: `Based on my analysis of BULLA token, here's what I found:
|
||||
|
||||
**Safety Score: 72/100** ⚠️ Medium Risk
|
||||
|
||||
**✅ Positive Signals:**
|
||||
- Liquidity is locked for 12 months
|
||||
- Contract is verified
|
||||
- Active for 45 days
|
||||
|
||||
**⚠️ Warnings:**
|
||||
- Top 10 holders own 35% of supply
|
||||
- Volume decreased 40% recently
|
||||
|
||||
**🚨 Red Flags:**
|
||||
- Mint authority is NOT revoked
|
||||
|
||||
**Recommendation:** Proceed with caution. The unlocked mint authority is a significant risk factor.`,
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 4),
|
||||
thinkingSteps: [
|
||||
{ id: "1", type: "thinking" as const, title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching" as const, title: "Fetching token data from DexScreener...", isComplete: true },
|
||||
{ id: "3", type: "analyzing" as const, title: "Running safety analysis...", isComplete: true },
|
||||
{ id: "4", type: "complete" as const, title: "Analysis complete", isComplete: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// FEATURE FLAGS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_MODE = {
|
||||
/** Enable mock data for development/testing */
|
||||
enabled: true,
|
||||
/** Simulate DexScreener page context */
|
||||
simulateDexScreener: true,
|
||||
/** Show mock watchlist data */
|
||||
showWatchlist: true,
|
||||
/** Show mock alerts */
|
||||
showAlerts: true,
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
148
surfsense_browser_extension/sidepanel/widgets/AlertWidget.tsx
Normal file
148
surfsense_browser_extension/sidepanel/widgets/AlertWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, CheckCircle, Star, Bell } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TokenAnalysisData {
|
||||
/** Token symbol */
|
||||
symbol: string;
|
||||
/** Token name */
|
||||
name?: string;
|
||||
/** Blockchain */
|
||||
chain: string;
|
||||
/** Current price */
|
||||
price: string;
|
||||
/** 24h price change */
|
||||
priceChange24h: number;
|
||||
/** Market cap */
|
||||
marketCap?: string;
|
||||
/** 24h volume */
|
||||
volume24h?: string;
|
||||
/** Liquidity */
|
||||
liquidity?: string;
|
||||
/** Safety score (0-100) */
|
||||
safetyScore?: number;
|
||||
/** Holder count */
|
||||
holderCount?: number;
|
||||
/** Top 10 holder percentage */
|
||||
top10HolderPercent?: number;
|
||||
}
|
||||
|
||||
export interface TokenAnalysisWidgetProps {
|
||||
/** Token analysis data */
|
||||
data: TokenAnalysisData;
|
||||
/** Whether token is in watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
/** Callback when add to watchlist is clicked */
|
||||
onAddToWatchlist?: () => void;
|
||||
/** Callback when set alert is clicked */
|
||||
onSetAlert?: () => void;
|
||||
/** Callback when analyze further is clicked */
|
||||
onAnalyzeFurther?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenAnalysisWidget - Full token analysis card embedded in chat
|
||||
*
|
||||
* Displays comprehensive token analysis including price, safety score,
|
||||
* and key metrics. Used when AI responds to token research queries.
|
||||
*/
|
||||
export function TokenAnalysisWidget({
|
||||
data,
|
||||
isInWatchlist = false,
|
||||
onAddToWatchlist,
|
||||
onSetAlert,
|
||||
onAnalyzeFurther,
|
||||
className,
|
||||
}: TokenAnalysisWidgetProps) {
|
||||
const getSafetyColor = (score?: number) => {
|
||||
if (!score) return "text-muted-foreground";
|
||||
if (score >= 80) return "text-green-500";
|
||||
if (score >= 60) return "text-yellow-500";
|
||||
return "text-red-500";
|
||||
};
|
||||
|
||||
const getSafetyLabel = (score?: number) => {
|
||||
if (!score) return "Unknown";
|
||||
if (score >= 80) return "Low Risk";
|
||||
if (score >= 60) return "Medium Risk";
|
||||
return "High Risk";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="font-medium text-sm">TokenAnalysisCard</span>
|
||||
</div>
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Token info */}
|
||||
<div className="flex items-center gap-3 mb-3 pb-3 border-b">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-lg">🪙</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.symbol}</span>
|
||||
{data.name && (
|
||||
<span className="text-xs text-muted-foreground">{data.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{data.price}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddToWatchlist}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-colors",
|
||||
isInWatchlist
|
||||
? "text-yellow-500 bg-yellow-500/10"
|
||||
: "text-muted-foreground hover:text-yellow-500 hover:bg-yellow-500/10"
|
||||
)}
|
||||
>
|
||||
<Star className={cn("h-5 w-5", isInWatchlist && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3 text-sm">
|
||||
{data.marketCap && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{data.marketCap}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.volume24h && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{data.volume24h}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.liquidity && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{data.liquidity}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.safetyScore !== undefined && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Safety Score</p>
|
||||
<p className={cn("font-medium flex items-center gap-1", getSafetyColor(data.safetyScore))}>
|
||||
<Shield className="h-3 w-3" />
|
||||
{data.safetyScore}/100 ({getSafetyLabel(data.safetyScore)})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holder info */}
|
||||
{(data.holderCount || data.top10HolderPercent) && (
|
||||
<div className="flex items-center gap-4 mb-3 text-xs text-muted-foreground">
|
||||
{data.holderCount && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{data.holderCount.toLocaleString()} holders
|
||||
</span>
|
||||
)}
|
||||
{data.top10HolderPercent && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
data.top10HolderPercent > 50 ? "text-yellow-500" : ""
|
||||
)}>
|
||||
{data.top10HolderPercent > 50 && <AlertTriangle className="h-3 w-3" />}
|
||||
Top 10: {data.top10HolderPercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onAddToWatchlist} className="flex-1">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSetAlert}>
|
||||
<Bell className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="default" onClick={onAnalyzeFurther}>
|
||||
Analyze More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
9
surfsense_browser_extension/sidepanel/widgets/index.ts
Normal file
9
surfsense_browser_extension/sidepanel/widgets/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Conversational UX Widgets for SurfSense Browser Extension
|
||||
// These widgets are embedded inline in chat messages for a conversation-first experience
|
||||
|
||||
export { ActionConfirmationWidget, type ActionConfirmationProps } from "./ActionConfirmationWidget";
|
||||
export { ProactiveAlertCard, type ProactiveAlertCardProps, type ProactiveAlertData } from "./ProactiveAlertCard";
|
||||
export { WatchlistWidget, type WatchlistWidgetProps, type WatchlistItem } from "./WatchlistWidget";
|
||||
export { AlertWidget, type AlertWidgetProps, type AlertConfigData } from "./AlertWidget";
|
||||
export { TokenAnalysisWidget, type TokenAnalysisWidgetProps, type TokenAnalysisData } from "./TokenAnalysisWidget";
|
||||
|
||||
|
|
@ -0,0 +1,770 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, Star, Bell, ExternalLink, Trash2, Plus, Activity, Zap, CheckCircle, Eye, Settings, Edit2, Percent, DollarSign, X, Flame, Fish, ArrowUpRight, ArrowDownRight, Globe, Wallet, User as UserIcon, Target } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
import { SafetyBadge } from "@/components/crypto/SafetyBadge";
|
||||
|
||||
// ============ MOCK DATA ============
|
||||
const MOCK_TOKEN_ANALYSIS = {
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "BULLAxK9xGJxGqPwPqTbGpLd9yKthvfNUet9V8wj8rWD",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
marketCap: 2100000,
|
||||
volume24h: 1200000,
|
||||
liquidity: 450000,
|
||||
safetyScore: 72,
|
||||
holderCount: 12500,
|
||||
top10HolderPercent: 45,
|
||||
};
|
||||
|
||||
const MOCK_WATCHLIST = [
|
||||
{ id: "1", symbol: "BULLA", name: "Bulla Token", chain: "solana", price: 0.00001234, priceChange24h: 156.7, alertCount: 2 },
|
||||
{ id: "2", symbol: "SOL", name: "Solana", chain: "solana", price: 98.45, priceChange24h: 3.2, alertCount: 1 },
|
||||
{ id: "3", symbol: "BONK", name: "Bonk", chain: "solana", price: 0.00002156, priceChange24h: -12.5, alertCount: 0 },
|
||||
{ id: "4", symbol: "WIF", name: "dogwifhat", chain: "solana", price: 2.34, priceChange24h: -5.8, alertCount: 3 },
|
||||
];
|
||||
|
||||
const MOCK_ALERTS = [
|
||||
{ id: "1", type: "price_above" as const, value: 0.00002, enabled: true },
|
||||
{ id: "2", type: "price_below" as const, value: 0.000008, enabled: true },
|
||||
{ id: "3", type: "percent_change" as const, value: 20, enabled: false },
|
||||
{ id: "4", type: "whale_activity" as const, value: 50000, enabled: true },
|
||||
];
|
||||
|
||||
const MOCK_PROACTIVE_ALERT = {
|
||||
alertType: "price_surge" as const,
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
value: 0.00001234,
|
||||
previousValue: 0.00000482,
|
||||
message: "BULLA just surged 156% in the last hour! This is unusual activity - consider taking profits or setting a stop-loss.",
|
||||
severity: "warning" as const,
|
||||
timestamp: "2 min ago",
|
||||
};
|
||||
|
||||
const MOCK_ACTION_CONFIRMATION = {
|
||||
actionType: "watchlist_add" as const,
|
||||
tokenSymbol: "BULLA",
|
||||
details: ["Price alerts (±10%)", "Whale activity monitoring", "Safety score changes"],
|
||||
};
|
||||
|
||||
// ============ HELPER FUNCTIONS ============
|
||||
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)}`;
|
||||
};
|
||||
|
||||
// ============ DEMO COMPONENTS ============
|
||||
|
||||
// 1. Token Analysis Demo
|
||||
function TokenAnalysisDemo() {
|
||||
const args = MOCK_TOKEN_ANALYSIS;
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
Token Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{args.symbol}</span>
|
||||
<span className="text-muted-foreground text-sm">{args.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatPrice(args.price)}</span>
|
||||
<span className={cn("flex items-center gap-0.5 text-sm font-medium", args.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{args.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{args.priceChange24h >= 0 ? "+" : ""}{args.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SafetyBadge score={args.safetyScore} size="lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.marketCap)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.volume24h)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.liquidity)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Holders</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{args.holderCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{args.top10HolderPercent > 50 && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Top 10 holders own {args.top10HolderPercent}% of supply - high concentration risk</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
<Star className="h-4 w-4 mr-2" />Add to Watchlist
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><Bell className="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="sm"><ExternalLink className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Watchlist Display Demo
|
||||
function WatchlistDisplayDemo() {
|
||||
const tokens = MOCK_WATCHLIST;
|
||||
const sortedByChange = [...tokens].sort((a, b) => b.priceChange24h - a.priceChange24h);
|
||||
const bestPerformer = sortedByChange[0];
|
||||
const worstPerformer = sortedByChange[sortedByChange.length - 1];
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
Your Watchlist
|
||||
<Badge variant="secondary">{tokens.length}</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm"><Plus className="h-4 w-4 mr-1" />Add Token</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
{token.alertCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
<Bell className="h-2.5 w-2.5 mr-0.5" />{token.alertCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm 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>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 border-t text-sm text-muted-foreground">
|
||||
<span className="text-green-500 font-medium">{bestPerformer.symbol}</span> is your best performer (+{bestPerformer.priceChange24h.toFixed(1)}%)
|
||||
{worstPerformer.priceChange24h < 0 && (
|
||||
<span> • <span className="text-red-500 font-medium">{worstPerformer.symbol}</span> needs attention ({worstPerformer.priceChange24h.toFixed(1)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Action Confirmation Demo
|
||||
function ActionConfirmationDemo() {
|
||||
const args = MOCK_ACTION_CONFIRMATION;
|
||||
return (
|
||||
<Card className="overflow-hidden border-l-4 border-l-green-500">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-yellow-500/10">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span className="font-medium">Added to Watchlist</span>
|
||||
<Badge variant="secondary" className="font-mono">{args.tokenSymbol}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mb-1">Default monitoring enabled:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{args.details.map((detail, i) => (<li key={i}>{detail}</li>))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4 ml-11">
|
||||
<Button variant="outline" size="sm"><Eye className="h-3 w-3 mr-1" />View Watchlist</Button>
|
||||
<Button variant="outline" size="sm"><Settings className="h-3 w-3 mr-1" />Edit Alerts</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Alert Configuration Demo
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_above: { icon: TrendingUp, label: "Price Above", color: "text-green-500" },
|
||||
price_below: { icon: TrendingDown, label: "Price Below", color: "text-red-500" },
|
||||
percent_change: { icon: Percent, label: "% Change", color: "text-blue-500" },
|
||||
volume_spike: { icon: Activity, label: "Volume Spike", color: "text-purple-500" },
|
||||
whale_activity: { icon: DollarSign, label: "Whale Activity", color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const formatAlertValue = (type: string, value: number): string => {
|
||||
if (type === "percent_change") return `${value > 0 ? "+" : ""}${value}%`;
|
||||
if (type === "volume_spike") return `${value}x normal`;
|
||||
if (type === "whale_activity") return `>${value.toLocaleString()} USD`;
|
||||
return `$${value < 1 ? value.toFixed(6) : value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
function AlertConfigurationDemo() {
|
||||
const alerts = MOCK_ALERTS;
|
||||
const enabledCount = alerts.filter(a => a.enabled).length;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-blue-500" />
|
||||
Alerts for BULLA
|
||||
<Badge variant="secondary">{enabledCount} active</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm"><Bell className="h-4 w-4 mr-1" />Add Alert</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={alert.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<div>
|
||||
<p className="font-medium">{config.label}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatAlertValue(alert.type, alert.value)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={alert.enabled} />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><Edit2 className="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500"><Trash2 className="h-3 w-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Proactive Alert Demo
|
||||
function ProactiveAlertDemo() {
|
||||
const args = MOCK_PROACTIVE_ALERT;
|
||||
const change = ((args.value - args.previousValue) / args.previousValue) * 100;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-l-4 border-l-green-500">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-green-500/10">
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase text-xs">PRICE SURGE</Badge>
|
||||
<span className="font-bold">{args.tokenSymbol}</span>
|
||||
<span className="font-medium text-green-500">+{change.toFixed(1)}%</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{args.timestamp}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{args.message}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground"><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3 ml-11">
|
||||
<Button variant="outline" size="sm"><Eye className="h-3 w-3 mr-1" />View Details</Button>
|
||||
<Button variant="outline" size="sm"><Bell className="h-3 w-3 mr-1" />Adjust Alert</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Trending Tokens Demo
|
||||
const MOCK_TRENDING = [
|
||||
{ symbol: "BULLA", name: "Bulla Token", chain: "solana", price: 0.00001234, priceChange24h: 156.7, volume24h: 1200000, rank: 1 },
|
||||
{ symbol: "POPCAT", name: "Popcat", chain: "solana", price: 0.89, priceChange24h: 45.2, volume24h: 8500000, rank: 2 },
|
||||
{ symbol: "WIF", name: "dogwifhat", chain: "solana", price: 2.34, priceChange24h: 32.1, volume24h: 15000000, rank: 3 },
|
||||
{ symbol: "BONK", name: "Bonk", chain: "solana", price: 0.00002156, priceChange24h: 18.5, volume24h: 5200000, rank: 4 },
|
||||
];
|
||||
|
||||
function TrendingTokensDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
Trending on Solana
|
||||
<Badge variant="secondary">24h</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{MOCK_TRENDING.map((token) => (
|
||||
<div key={token.symbol} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted-foreground w-6">#{token.rank}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
+{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right hidden md:block">
|
||||
<p className="text-xs text-muted-foreground">Volume</p>
|
||||
<p className="text-sm">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><Star className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Whale Activity Demo
|
||||
const MOCK_WHALE_TXS = [
|
||||
{ id: "1", type: "buy" as const, amountUsd: 250000, walletLabel: "Smart Money 1", timestamp: "5m ago" },
|
||||
{ id: "2", type: "sell" as const, amountUsd: 180000, walletLabel: "Whale #42", timestamp: "12m ago" },
|
||||
{ id: "3", type: "buy" as const, amountUsd: 320000, walletLabel: null, timestamp: "25m ago" },
|
||||
{ id: "4", type: "transfer" as const, amountUsd: 500000, walletLabel: "Exchange Hot Wallet", timestamp: "1h ago" },
|
||||
];
|
||||
|
||||
function WhaleActivityDemo() {
|
||||
const summary = { totalBuyVolume: 570000, totalSellVolume: 180000, netFlow: 390000, uniqueWhales: 4 };
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="h-5 w-5 text-blue-500" />
|
||||
Whale Activity - BULLA
|
||||
</div>
|
||||
<ChainIcon chain="solana" size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Buy Volume</p>
|
||||
<p className="font-medium text-green-500">{formatLargeNumber(summary.totalBuyVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Sell Volume</p>
|
||||
<p className="font-medium text-red-500">{formatLargeNumber(summary.totalSellVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Net Flow</p>
|
||||
<p className="font-medium text-green-500">+{formatLargeNumber(summary.netFlow)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Unique Whales</p>
|
||||
<p className="font-medium">{summary.uniqueWhales}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_WHALE_TXS.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-full", tx.type === "buy" ? "bg-green-500/10" : tx.type === "sell" ? "bg-red-500/10" : "bg-muted")}>
|
||||
{tx.type === "buy" ? <ArrowUpRight className="h-4 w-4 text-green-500" /> : tx.type === "sell" ? <ArrowDownRight className="h-4 w-4 text-red-500" /> : <ArrowUpRight className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className={cn("font-medium capitalize", tx.type === "buy" ? "text-green-500" : tx.type === "sell" ? "text-red-500" : "")}>{tx.type}</span>
|
||||
<span className="font-medium ml-2">{formatLargeNumber(tx.amountUsd)}</span>
|
||||
<p className="text-xs text-muted-foreground">{tx.walletLabel || "Unknown Wallet"} • {tx.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Market Overview Demo
|
||||
const MOCK_MARKET = [
|
||||
{ symbol: "BTC", name: "Bitcoin", price: 67500, priceChange24h: 2.3 },
|
||||
{ symbol: "ETH", name: "Ethereum", price: 3450, priceChange24h: -1.2 },
|
||||
{ symbol: "SOL", name: "Solana", price: 98.45, priceChange24h: 5.7 },
|
||||
];
|
||||
|
||||
function MarketOverviewDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
Market Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium">$2.45T</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">$89.2B</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium">52.3%</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Fear & Greed</p>
|
||||
<p className="font-medium text-green-500">72 - Greed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{MOCK_MARKET.map((token) => (
|
||||
<div key={token.symbol} className="bg-muted/50 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-lg">{token.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${token.price.toLocaleString()}</p>
|
||||
<p className={cn("text-sm", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 9. Holder Analysis Demo
|
||||
const MOCK_HOLDERS = [
|
||||
{ rank: 1, address: "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", label: "Raydium LP", percentage: 15.2, balance: 152000000 },
|
||||
{ rank: 2, address: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", label: null, percentage: 8.5, balance: 85000000 },
|
||||
{ rank: 3, address: "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH", label: "Team Wallet", percentage: 7.2, balance: 72000000 },
|
||||
];
|
||||
|
||||
function HolderAnalysisDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
Holder Analysis - BULLA
|
||||
</div>
|
||||
<ChainIcon chain="solana" size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium">12,500</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
|
||||
<p className="font-medium text-red-500">45.2%</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium">68.5%</p>
|
||||
</div>
|
||||
<div className="bg-yellow-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium text-yellow-500">Medium</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_HOLDERS.map((holder) => (
|
||||
<div key={holder.rank} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-6">#{holder.rank}</span>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{holder.label || `${holder.address.slice(0, 6)}...${holder.address.slice(-4)}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-xs text-muted-foreground">{(holder.balance / 1e6).toFixed(1)}M</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Portfolio Display Demo
|
||||
const MOCK_PORTFOLIO_HOLDINGS = [
|
||||
{ symbol: "SOL", name: "Solana", chain: "solana", balance: 50, value: 4922.5, pnlPercent: 125.5, allocation: 45 },
|
||||
{ symbol: "BULLA", name: "Bulla Token", chain: "solana", balance: 5000000, value: 61.7, pnlPercent: 256.7, allocation: 5.6 },
|
||||
{ symbol: "ETH", name: "Ethereum", chain: "ethereum", balance: 1.5, value: 5175, pnlPercent: 45.2, allocation: 47.3 },
|
||||
];
|
||||
|
||||
function PortfolioDisplayDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-emerald-500" />
|
||||
Your Portfolio
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-gradient-to-r from-emerald-500/10 to-blue-500/10 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Value</p>
|
||||
<p className="text-3xl font-bold">$10,934.20</p>
|
||||
<p className="text-sm text-green-500 flex items-center gap-1 mt-1">
|
||||
<TrendingUp className="h-4 w-4" />+$3,245.80 (+42.3%)
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_PORTFOLIO_HOLDINGS.map((holding) => (
|
||||
<div key={holding.symbol} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={holding.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{holding.symbol}</span>
|
||||
<Badge variant="secondary" className="text-xs">{holding.allocation.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{holding.balance.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${holding.value.toLocaleString()}</p>
|
||||
<p className={cn("text-sm", holding.pnlPercent >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
+{holding.pnlPercent.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 11. User Profile Demo
|
||||
function UserProfileDemo() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserIcon className="h-5 w-5 text-indigo-500" />
|
||||
Your Investment Profile
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg p-4 border text-yellow-500 bg-yellow-500/10 border-yellow-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Risk Tolerance</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">Moderate</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Balance between risk and reward</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-4 border bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Investment Style</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">Swing Trader</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold for days to weeks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Preferred Chains</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">Solana</Badge>
|
||||
<Badge variant="default">Ethereum</Badge>
|
||||
<Badge variant="outline">Base</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
Say "update my risk tolerance to aggressive" to change settings
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CryptoToolsDemoPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">🧪 Crypto Tools Demo</h1>
|
||||
<p className="text-muted-foreground">Preview of all crypto tool UI components with mock data</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">These components render inline in chat when AI calls the corresponding tools.</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">1</span>
|
||||
Token Analysis <code className="text-xs bg-muted px-2 py-1 rounded ml-2">analyze_token</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Analyze BULLA", "Is BULLA safe?", "Research this token"</p>
|
||||
<TokenAnalysisDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">2</span>
|
||||
Watchlist Display <code className="text-xs bg-muted px-2 py-1 rounded ml-2">show_watchlist</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my watchlist", "What tokens am I tracking?"</p>
|
||||
<WatchlistDisplayDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">3</span>
|
||||
Action Confirmation <code className="text-xs bg-muted px-2 py-1 rounded ml-2">confirm_action</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Add BULLA to watchlist", "Remove SOL from watchlist"</p>
|
||||
<ActionConfirmationDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">4</span>
|
||||
Alert Configuration <code className="text-xs bg-muted px-2 py-1 rounded ml-2">configure_alerts</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my alerts for BULLA", "Set alert if BULLA drops 20%"</p>
|
||||
<AlertConfigurationDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-blue-500/10 text-blue-500 px-2 py-1 rounded text-sm">5</span>
|
||||
Proactive Alert <code className="text-xs bg-muted px-2 py-1 rounded ml-2">proactive_alert</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">AI-initiated: Automatically sent when price surges, whale activity detected, etc.</p>
|
||||
<ProactiveAlertDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">6</span>
|
||||
Trending Tokens <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_trending_tokens</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "What's hot on Solana?", "Show trending tokens", "What's pumping today?"</p>
|
||||
<TrendingTokensDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">7</span>
|
||||
Whale Activity <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_whale_activity</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show whale activity for BULLA", "Any big buys?", "Who's accumulating?"</p>
|
||||
<WhaleActivityDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">8</span>
|
||||
Market Overview <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_market_overview</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "How's the market?", "Show market overview", "What's the sentiment?"</p>
|
||||
<MarketOverviewDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">9</span>
|
||||
Holder Analysis <code className="text-xs bg-muted px-2 py-1 rounded ml-2">analyze_holders</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Who holds BULLA?", "Show holder distribution", "Is it concentrated?"</p>
|
||||
<HolderAnalysisDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">10</span>
|
||||
Portfolio Display <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_portfolio</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "How's my portfolio?", "Show my holdings", "What's my P&L?"</p>
|
||||
<PortfolioDisplayDemo />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="bg-orange-500/10 text-orange-500 px-2 py-1 rounded text-sm">11</span>
|
||||
User Profile <code className="text-xs bg-muted px-2 py-1 rounded ml-2">get_user_profile</code>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">Triggered by: "Show my profile", "What's my risk tolerance?", "Update my investment style"</p>
|
||||
<UserProfileDemo />
|
||||
</section>
|
||||
</div>
|
||||
<div className="mt-12 p-4 bg-muted/50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">💡 How it works</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When you chat with the AI and ask crypto-related questions, the AI calls these tools and the corresponding UI components render inline in the chat.
|
||||
This creates a seamless conversational experience where data and actions are embedded directly in the conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||
<h3 className="font-semibold mb-2 text-green-600">✅ All 11 Tool-UI Components Complete</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Components 1-5 (blue) are the original tools. Components 6-11 (orange) are newly added to cover all crypto features in the spec.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
272
surfsense_web/app/dashboard/[search_space_id]/crypto/page.tsx
Normal file
272
surfsense_web/app/dashboard/[search_space_id]/crypto/page.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MessageSquare, Sparkles, Star, Bell, TrendingUp, ArrowRight, X, Plus, User } from "lucide-react";
|
||||
import {
|
||||
MarketOverview,
|
||||
WatchlistTable,
|
||||
AlertsPanel,
|
||||
PortfolioSummary,
|
||||
AddTokenModal,
|
||||
CreateAlertModal,
|
||||
UserProfileSection,
|
||||
type AlertConfig,
|
||||
type UserProfile,
|
||||
} from "@/components/crypto";
|
||||
import {
|
||||
MOCK_MARKET_PRICES,
|
||||
MOCK_WATCHLIST,
|
||||
MOCK_ALERTS,
|
||||
MOCK_PORTFOLIO,
|
||||
} from "@/lib/mock/cryptoMockData";
|
||||
|
||||
// Default user profile
|
||||
const DEFAULT_PROFILE: UserProfile = {
|
||||
riskTolerance: "moderate",
|
||||
investmentStyle: "swing",
|
||||
preferredChains: ["solana", "ethereum"],
|
||||
notifications: {
|
||||
priceAlerts: true,
|
||||
whaleAlerts: true,
|
||||
newsAlerts: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Crypto Dashboard Page
|
||||
*
|
||||
* Full-featured crypto management dashboard with:
|
||||
* - Market Overview
|
||||
* - Watchlist Management (with Add Token modal)
|
||||
* - Alerts Management (with Create Alert modal)
|
||||
* - Portfolio Summary
|
||||
* - User Profile Settings
|
||||
*
|
||||
* Also includes a banner promoting the AI Chat for research & analysis.
|
||||
*/
|
||||
export default function CryptoDashboardPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id;
|
||||
|
||||
// UI State
|
||||
const [showAIBanner, setShowAIBanner] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("watchlist");
|
||||
const [showAddTokenModal, setShowAddTokenModal] = useState(false);
|
||||
const [showCreateAlertModal, setShowCreateAlertModal] = useState(false);
|
||||
const [alertPrefilledToken, setAlertPrefilledToken] = useState<{ symbol: string; chain: string } | undefined>();
|
||||
|
||||
// Data State (mock - would be from API in production)
|
||||
const [watchlist, setWatchlist] = useState(MOCK_WATCHLIST);
|
||||
const [alerts, setAlerts] = useState(MOCK_ALERTS);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile>(DEFAULT_PROFILE);
|
||||
|
||||
const handleGoToChat = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
};
|
||||
|
||||
const handleTokenClick = (token: any) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat?query=Analyze ${token.symbol}`);
|
||||
};
|
||||
|
||||
const handleConfigureAlerts = (token: any) => {
|
||||
setAlertPrefilledToken({ symbol: token.symbol, chain: token.chain });
|
||||
setShowCreateAlertModal(true);
|
||||
};
|
||||
|
||||
const handleAlertClick = (alert: any) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat?query=Tell me about ${alert.tokenSymbol}`);
|
||||
};
|
||||
|
||||
const handleRemoveToken = (tokenId: string) => {
|
||||
setWatchlist((prev) => prev.filter((t) => t.id !== tokenId));
|
||||
};
|
||||
|
||||
const handleAddToken = (token: { symbol: string; name: string; chain: string; contractAddress?: string }) => {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
chain: token.chain,
|
||||
contractAddress: token.contractAddress,
|
||||
price: 0,
|
||||
priceChange24h: 0,
|
||||
safetyScore: undefined,
|
||||
alertCount: 0,
|
||||
};
|
||||
setWatchlist((prev) => [...prev, newToken]);
|
||||
};
|
||||
|
||||
const handleCreateAlert = (alertConfig: AlertConfig) => {
|
||||
const newAlert = {
|
||||
id: `alert-${Date.now()}`,
|
||||
tokenSymbol: alertConfig.tokenSymbol,
|
||||
chain: alertConfig.chain,
|
||||
type: alertConfig.alertType,
|
||||
message: `${alertConfig.alertType.replace("_", " ")} alert for ${alertConfig.tokenSymbol}`,
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
isRead: false,
|
||||
};
|
||||
setAlerts((prev) => [newAlert, ...prev]);
|
||||
};
|
||||
|
||||
const handleSaveProfile = (profile: UserProfile) => {
|
||||
setUserProfile(profile);
|
||||
// In production, save to backend
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* AI Chat Promotion Banner */}
|
||||
{showAIBanner && (
|
||||
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
💡 Try our AI Crypto Advisor for deeper analysis!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask questions like "Is BULLA safe?" or "Set alert if SOL drops 10%"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleGoToChat} className="gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Open AI Chat
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowAIBanner(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-4 md:p-6 max-w-7xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
🚀 Crypto Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Manage your watchlist, alerts, and track market trends
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="watchlist" className="gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Watchlist
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="alerts" className="gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Alerts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="market" className="gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Market
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Watchlist Tab */}
|
||||
<TabsContent value="watchlist" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowAddTokenModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<WatchlistTable
|
||||
tokens={watchlist}
|
||||
onTokenClick={handleTokenClick}
|
||||
onConfigureAlerts={handleConfigureAlerts}
|
||||
onRemoveToken={handleRemoveToken}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PortfolioSummary portfolio={MOCK_PORTFOLIO} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Alerts Tab */}
|
||||
<TabsContent value="alerts" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setAlertPrefilledToken(undefined); setShowCreateAlertModal(true); }} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Alert
|
||||
</Button>
|
||||
</div>
|
||||
<AlertsPanel
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Market Tab */}
|
||||
<TabsContent value="market" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<MarketOverview tokens={MOCK_MARKET_PRICES} />
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Sparkles className="h-12 w-12 text-primary/50 mb-4" />
|
||||
<h3 className="font-semibold mb-2">Want deeper market insights?</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ask our AI about trending tokens, market sentiment, or specific analysis
|
||||
</p>
|
||||
<Button onClick={handleGoToChat} className="gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Ask AI Advisor
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="max-w-xl">
|
||||
<UserProfileSection
|
||||
profile={userProfile}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddTokenModal
|
||||
open={showAddTokenModal}
|
||||
onOpenChange={setShowAddTokenModal}
|
||||
onAddToken={handleAddToken}
|
||||
/>
|
||||
<CreateAlertModal
|
||||
open={showCreateAlertModal}
|
||||
onOpenChange={setShowCreateAlertModal}
|
||||
onCreateAlert={handleCreateAlert}
|
||||
prefilledToken={alertPrefilledToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
surfsense_web/components/crypto/AddTokenModal.tsx
Normal file
150
surfsense_web/components/crypto/AddTokenModal.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface AddTokenModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddToken: (token: { symbol: string; name: string; chain: string; contractAddress?: string }) => void;
|
||||
}
|
||||
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "solana", label: "Solana" },
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "base", label: "Base" },
|
||||
{ value: "arbitrum", label: "Arbitrum" },
|
||||
{ value: "polygon", label: "Polygon" },
|
||||
];
|
||||
|
||||
export function AddTokenModal({ open, onOpenChange, onAddToken }: AddTokenModalProps) {
|
||||
const [symbol, setSymbol] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [chain, setChain] = useState("solana");
|
||||
const [contractAddress, setContractAddress] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!symbol.trim()) {
|
||||
setError("Token symbol is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chain) {
|
||||
setError("Please select a chain");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onAddToken({
|
||||
symbol: symbol.toUpperCase().trim(),
|
||||
name: name.trim() || symbol.toUpperCase().trim(),
|
||||
chain,
|
||||
contractAddress: contractAddress.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setSymbol("");
|
||||
setName("");
|
||||
setChain("solana");
|
||||
setContractAddress("");
|
||||
setIsLoading(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Add Token to Watchlist
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="symbol">Token Symbol *</Label>
|
||||
<Input
|
||||
id="symbol"
|
||||
placeholder="e.g., BULLA, SOL, ETH"
|
||||
value={symbol}
|
||||
onChange={(e) => setSymbol(e.target.value)}
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Token Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Bulla Token"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chain">Chain *</Label>
|
||||
<Select value={chain} onValueChange={setChain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CHAINS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="contract">Contract Address (optional)</Label>
|
||||
<Input
|
||||
id="contract"
|
||||
placeholder="0x... or token mint address"
|
||||
value={contractAddress}
|
||||
onChange={(e) => setContractAddress(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide contract address for accurate token identification
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add to Watchlist
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
171
surfsense_web/components/crypto/AlertsPanel.tsx
Normal file
171
surfsense_web/components/crypto/AlertsPanel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bell, BellOff, Check, AlertTriangle, Info, XCircle } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import type { Alert } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface AlertsPanelProps {
|
||||
alerts: Alert[];
|
||||
onAlertClick?: (alert: Alert) => void;
|
||||
onMarkAsRead?: (alertId: string) => void;
|
||||
onMarkAllAsRead?: () => void;
|
||||
onDismiss?: (alertId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function getSeverityConfig(severity: Alert["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-yellow-500",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Info,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/20",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function AlertItem({
|
||||
alert,
|
||||
onClick,
|
||||
onMarkAsRead,
|
||||
onDismiss,
|
||||
}: {
|
||||
alert: Alert;
|
||||
onClick?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const config = getSeverityConfig(alert.severity);
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
|
||||
config.bg,
|
||||
config.border,
|
||||
!alert.isRead && "ring-1 ring-primary/20",
|
||||
"hover:bg-muted/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={cn("mt-0.5", config.color)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ChainIcon chain={alert.chain} size="sm" />
|
||||
<span className="font-medium text-sm">{alert.tokenSymbol}</span>
|
||||
{!alert.isRead && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[10px]">NEW</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{alert.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{formatTimeAgo(alert.timestamp)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{!alert.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead?.();
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsPanel({
|
||||
alerts,
|
||||
onAlertClick,
|
||||
onMarkAsRead,
|
||||
onMarkAllAsRead,
|
||||
onDismiss,
|
||||
className,
|
||||
}: AlertsPanelProps) {
|
||||
const unreadCount = alerts.filter((a) => !a.isRead).length;
|
||||
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" /> Alerts
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1">{unreadCount}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={onMarkAllAsRead}>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<BellOff className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">No alerts yet</p>
|
||||
<p className="text-xs">Configure alerts on your watchlist tokens</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<AlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onClick={() => onAlertClick?.(alert)}
|
||||
onMarkAsRead={() => onMarkAsRead?.(alert.id)}
|
||||
onDismiss={() => onDismiss?.(alert.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
48
surfsense_web/components/crypto/ChainIcon.tsx
Normal file
48
surfsense_web/components/crypto/ChainIcon.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChainType } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface ChainIconProps {
|
||||
chain: ChainType;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showName?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const chainConfig: Record<ChainType, { color: string; icon: string; name: string }> = {
|
||||
solana: { color: "#9945FF", icon: "◎", name: "Solana" },
|
||||
ethereum: { color: "#627EEA", icon: "Ξ", name: "Ethereum" },
|
||||
base: { color: "#0052FF", icon: "🔵", name: "Base" },
|
||||
arbitrum: { color: "#28A0F0", icon: "🔷", name: "Arbitrum" },
|
||||
polygon: { color: "#8247E5", icon: "⬡", name: "Polygon" },
|
||||
bsc: { color: "#F0B90B", icon: "⬢", name: "BNB Chain" },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4 text-xs",
|
||||
md: "h-5 w-5 text-sm",
|
||||
lg: "h-6 w-6 text-base",
|
||||
};
|
||||
|
||||
export function ChainIcon({ chain, size = "md", showName = false, className }: ChainIconProps) {
|
||||
const config = chainConfig[chain] || { color: "#888888", icon: "?", name: chain };
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: `${config.color}20`, color: config.color }}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
{showName && (
|
||||
<span className="text-sm text-muted-foreground">{config.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
surfsense_web/components/crypto/CreateAlertModal.tsx
Normal file
166
surfsense_web/components/crypto/CreateAlertModal.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Bell, Loader2 } from "lucide-react";
|
||||
|
||||
interface CreateAlertModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateAlert: (alert: AlertConfig) => void;
|
||||
prefilledToken?: { symbol: string; chain: string };
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
tokenSymbol: string;
|
||||
chain: string;
|
||||
alertType: string;
|
||||
threshold?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ value: "price_above", label: "Price Above", hasThreshold: true, unit: "$" },
|
||||
{ value: "price_below", label: "Price Below", hasThreshold: true, unit: "$" },
|
||||
{ value: "price_change", label: "Price Change %", hasThreshold: true, unit: "%" },
|
||||
{ value: "volume_spike", label: "Volume Spike", hasThreshold: true, unit: "x" },
|
||||
{ value: "whale_buy", label: "Whale Buy", hasThreshold: false },
|
||||
{ value: "whale_sell", label: "Whale Sell", hasThreshold: false },
|
||||
];
|
||||
|
||||
const SUPPORTED_CHAINS = [
|
||||
{ value: "solana", label: "Solana" },
|
||||
{ value: "ethereum", label: "Ethereum" },
|
||||
{ value: "base", label: "Base" },
|
||||
];
|
||||
|
||||
export function CreateAlertModal({ open, onOpenChange, onCreateAlert, prefilledToken }: CreateAlertModalProps) {
|
||||
const [tokenSymbol, setTokenSymbol] = useState(prefilledToken?.symbol || "");
|
||||
const [chain, setChain] = useState(prefilledToken?.chain || "solana");
|
||||
const [alertType, setAlertType] = useState("price_above");
|
||||
const [threshold, setThreshold] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selectedAlertType = ALERT_TYPES.find((t) => t.value === alertType);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!tokenSymbol.trim()) {
|
||||
setError("Token symbol is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAlertType?.hasThreshold && !threshold) {
|
||||
setError("Threshold value is required for this alert type");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onCreateAlert({
|
||||
tokenSymbol: tokenSymbol.toUpperCase().trim(),
|
||||
chain,
|
||||
alertType,
|
||||
threshold: selectedAlertType?.hasThreshold ? parseFloat(threshold) : undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setTokenSymbol("");
|
||||
setAlertType("price_above");
|
||||
setThreshold("");
|
||||
setEnabled(true);
|
||||
setIsLoading(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Create Alert
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="token">Token Symbol *</Label>
|
||||
<Input
|
||||
id="token"
|
||||
placeholder="e.g., SOL"
|
||||
value={tokenSymbol}
|
||||
onChange={(e) => setTokenSymbol(e.target.value)}
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chain">Chain</Label>
|
||||
<Select value={chain} onValueChange={setChain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CHAINS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="alertType">Alert Type *</Label>
|
||||
<Select value={alertType} onValueChange={setAlertType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALERT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedAlertType?.hasThreshold && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="threshold">Threshold ({selectedAlertType.unit}) *</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder={`Enter value in ${selectedAlertType.unit}`}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enabled">Enable Alert</Label>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Creating...</> : <><Bell className="h-4 w-4 mr-2" />Create Alert</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
64
surfsense_web/components/crypto/MarketOverview.tsx
Normal file
64
surfsense_web/components/crypto/MarketOverview.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TokenPrice } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface MarketOverviewProps {
|
||||
tokens: TokenPrice[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MarketCard({ token }: { token: TokenPrice }) {
|
||||
const isPositive = token.priceChange24h > 0;
|
||||
const isNegative = token.priceChange24h < 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold">
|
||||
{token.icon || token.symbol.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{token.symbol}</div>
|
||||
<div className="text-xs text-muted-foreground">{token.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{formatPrice(token.price)}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-1 text-xs",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{formatPercent(token.priceChange24h)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketOverview({ tokens, className }: MarketOverviewProps) {
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span> Market Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tokens.map((token) => (
|
||||
<MarketCard key={token.symbol} token={token} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
133
surfsense_web/components/crypto/PortfolioSummary.tsx
Normal file
133
surfsense_web/components/crypto/PortfolioSummary.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Wallet, PieChart } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import type { PortfolioSummary as PortfolioSummaryType, PortfolioToken } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface PortfolioSummaryProps {
|
||||
portfolio: PortfolioSummaryType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
changePercent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
}) {
|
||||
const isPositive = change !== undefined && change > 0;
|
||||
const isNegative = change !== undefined && change < 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{change !== undefined && changePercent !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm mt-1",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
<span>{formatPrice(Math.abs(change))}</span>
|
||||
<span>({formatPercent(changePercent)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({ token }: { token: PortfolioToken }) {
|
||||
const isPositive = token.pnl > 0;
|
||||
const isNegative = token.pnl < 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium">{token.symbol}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{token.amount.toLocaleString()} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{formatPrice(token.value)}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500"
|
||||
)}
|
||||
>
|
||||
{formatPercent(token.pnlPercent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 text-right">
|
||||
<div className="text-sm text-muted-foreground">{token.allocation.toFixed(1)}%</div>
|
||||
<div className="h-1.5 w-full bg-muted rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${token.allocation}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioSummary({ portfolio, className }: PortfolioSummaryProps) {
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" /> Portfolio
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label="Total Value"
|
||||
value={formatPrice(portfolio.totalValue)}
|
||||
change={portfolio.change24h}
|
||||
changePercent={portfolio.change24hPercent}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total P&L"
|
||||
value={formatPrice(portfolio.totalPnl)}
|
||||
change={portfolio.totalPnl}
|
||||
changePercent={portfolio.totalPnlPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Holdings */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Holdings</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{portfolio.tokens.map((token) => (
|
||||
<TokenRow key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
58
surfsense_web/components/crypto/PriceDisplay.tsx
Normal file
58
surfsense_web/components/crypto/PriceDisplay.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { formatPrice, formatPercent } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
priceChange?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { price: "text-sm font-medium", change: "text-xs" },
|
||||
md: { price: "text-lg font-semibold", change: "text-sm" },
|
||||
lg: { price: "text-2xl font-bold", change: "text-base" },
|
||||
};
|
||||
|
||||
export function PriceDisplay({
|
||||
price,
|
||||
priceChange,
|
||||
size = "md",
|
||||
showIcon = true,
|
||||
className,
|
||||
}: PriceDisplayProps) {
|
||||
const isPositive = priceChange !== undefined && priceChange > 0;
|
||||
const isNegative = priceChange !== undefined && priceChange < 0;
|
||||
const isNeutral = priceChange === undefined || priceChange === 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-2", className)}>
|
||||
<span className={sizeClasses[size].price}>{formatPrice(price)}</span>
|
||||
{priceChange !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
sizeClasses[size].change,
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
isNeutral && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{showIcon && (
|
||||
<>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{isNeutral && <Minus className="h-3 w-3" />}
|
||||
</>
|
||||
)}
|
||||
{formatPercent(priceChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
surfsense_web/components/crypto/SafetyBadge.tsx
Normal file
64
surfsense_web/components/crypto/SafetyBadge.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, ShieldAlert, ShieldCheck, ShieldX } from "lucide-react";
|
||||
import { getSafetyLabel } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface SafetyBadgeProps {
|
||||
score: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showScore?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { badge: "px-1.5 py-0.5 text-xs", icon: "h-3 w-3" },
|
||||
md: { badge: "px-2 py-1 text-sm", icon: "h-4 w-4" },
|
||||
lg: { badge: "px-3 py-1.5 text-base", icon: "h-5 w-5" },
|
||||
};
|
||||
|
||||
function getScoreConfig(score: number) {
|
||||
if (score >= 80) {
|
||||
return {
|
||||
color: "bg-green-500/10 text-green-600 border-green-500/20",
|
||||
Icon: ShieldCheck,
|
||||
};
|
||||
}
|
||||
if (score >= 60) {
|
||||
return {
|
||||
color: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
|
||||
Icon: Shield,
|
||||
};
|
||||
}
|
||||
if (score >= 40) {
|
||||
return {
|
||||
color: "bg-orange-500/10 text-orange-600 border-orange-500/20",
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: "bg-red-500/10 text-red-600 border-red-500/20",
|
||||
Icon: ShieldX,
|
||||
};
|
||||
}
|
||||
|
||||
export function SafetyBadge({ score, size = "md", showScore = true, className }: SafetyBadgeProps) {
|
||||
const { color, Icon } = getScoreConfig(score);
|
||||
const label = getSafetyLabel(score);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border font-medium",
|
||||
color,
|
||||
sizeClasses[size].badge,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={sizeClasses[size].icon} />
|
||||
<span>{label}</span>
|
||||
{showScore && <span className="opacity-70">({score})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
165
surfsense_web/components/crypto/UserProfileSection.tsx
Normal file
165
surfsense_web/components/crypto/UserProfileSection.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { User, Shield, Target, Bell, Save, Loader2 } from "lucide-react";
|
||||
|
||||
export interface UserProfile {
|
||||
riskTolerance: "conservative" | "moderate" | "aggressive";
|
||||
investmentStyle: "day_trader" | "swing" | "long_term";
|
||||
preferredChains: string[];
|
||||
notifications: {
|
||||
priceAlerts: boolean;
|
||||
whaleAlerts: boolean;
|
||||
newsAlerts: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserProfileSectionProps {
|
||||
profile: UserProfile;
|
||||
onSave: (profile: UserProfile) => void;
|
||||
}
|
||||
|
||||
const CHAINS = ["solana", "ethereum", "base", "arbitrum", "polygon"];
|
||||
|
||||
export function UserProfileSection({ profile: initialProfile, onSave }: UserProfileSectionProps) {
|
||||
const [profile, setProfile] = useState<UserProfile>(initialProfile);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const updateProfile = (updates: Partial<UserProfile>) => {
|
||||
setProfile((prev) => ({ ...prev, ...updates }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const toggleChain = (chain: string) => {
|
||||
const newChains = profile.preferredChains.includes(chain)
|
||||
? profile.preferredChains.filter((c) => c !== chain)
|
||||
: [...profile.preferredChains, chain];
|
||||
updateProfile({ preferredChains: newChains });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
onSave(profile);
|
||||
setIsSaving(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Investment Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your risk preferences and notification settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Risk Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Risk Tolerance
|
||||
</Label>
|
||||
<Select
|
||||
value={profile.riskTolerance}
|
||||
onValueChange={(v) => updateProfile({ riskTolerance: v as UserProfile["riskTolerance"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="conservative">Conservative - Lower risk, stable returns</SelectItem>
|
||||
<SelectItem value="moderate">Moderate - Balanced risk/reward</SelectItem>
|
||||
<SelectItem value="aggressive">Aggressive - Higher risk, higher potential</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Investment Style */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Investment Style
|
||||
</Label>
|
||||
<Select
|
||||
value={profile.investmentStyle}
|
||||
onValueChange={(v) => updateProfile({ investmentStyle: v as UserProfile["investmentStyle"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day_trader">Day Trader - Quick trades, high frequency</SelectItem>
|
||||
<SelectItem value="swing">Swing Trader - Hold for days to weeks</SelectItem>
|
||||
<SelectItem value="long_term">Long Term - Hold for months to years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Preferred Chains */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preferred Chains</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CHAINS.map((chain) => (
|
||||
<Badge
|
||||
key={chain}
|
||||
variant={profile.preferredChains.includes(chain) ? "default" : "outline"}
|
||||
className="cursor-pointer capitalize"
|
||||
onClick={() => toggleChain(chain)}
|
||||
>
|
||||
{chain}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="space-y-4">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Price Alerts</span>
|
||||
<Switch
|
||||
checked={profile.notifications.priceAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, priceAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Whale Activity Alerts</span>
|
||||
<Switch
|
||||
checked={profile.notifications.whaleAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, whaleAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">News & Updates</span>
|
||||
<Switch
|
||||
checked={profile.notifications.newsAlerts}
|
||||
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, newsAlerts: v } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isSaving} className="w-full">
|
||||
{isSaving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : <><Save className="h-4 w-4 mr-2" />Save Profile</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
211
surfsense_web/components/crypto/WatchlistTable.tsx
Normal file
211
surfsense_web/components/crypto/WatchlistTable.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Star,
|
||||
Bell,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
ArrowUpDown,
|
||||
Trash2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "./ChainIcon";
|
||||
import { SafetyBadge } from "./SafetyBadge";
|
||||
import type { WatchlistToken } from "@/lib/mock/cryptoMockData";
|
||||
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
|
||||
|
||||
interface WatchlistTableProps {
|
||||
tokens: WatchlistToken[];
|
||||
onTokenClick?: (token: WatchlistToken) => void;
|
||||
onRemoveToken?: (tokenId: string) => void;
|
||||
onConfigureAlerts?: (token: WatchlistToken) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SortField = "symbol" | "price" | "priceChange24h" | "volume24h" | "marketCap" | "safetyScore";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
export function WatchlistTable({
|
||||
tokens,
|
||||
onTokenClick,
|
||||
onRemoveToken,
|
||||
onConfigureAlerts,
|
||||
className,
|
||||
}: WatchlistTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("priceChange24h");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedTokens = [...tokens].sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return sortDirection === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||||
}
|
||||
return sortDirection === "asc"
|
||||
? (aVal as number) - (bVal as number)
|
||||
: (bVal as number) - (aVal as number);
|
||||
});
|
||||
|
||||
const SortableHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
onClick={() => handleSort(field)}
|
||||
>
|
||||
{children}
|
||||
<ArrowUpDown className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" /> Watchlist
|
||||
<Badge variant="secondary" className="ml-2">{tokens.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">
|
||||
<SortableHeader field="symbol">Token</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader field="price">Price</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader field="priceChange24h">24h</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<SortableHeader field="volume24h">Volume</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
<SortableHeader field="marketCap">MCap</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
<SortableHeader field="safetyScore">Safety</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedTokens.map((token) => (
|
||||
<TableRow
|
||||
key={token.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{token.symbol}
|
||||
{token.hasAlerts && (
|
||||
<Bell className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{token.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{formatPrice(token.price)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
token.priceChange24h > 0 && "text-green-500",
|
||||
token.priceChange24h < 0 && "text-red-500"
|
||||
)}>
|
||||
{formatPercent(token.priceChange24h)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{formatLargeNumber(token.volume24h)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{formatLargeNumber(token.marketCap)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<SafetyBadge score={token.safetyScore} size="sm" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigureAlerts?.(token);
|
||||
}}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure Alerts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`https://dexscreener.com/${token.chain}/${token.contractAddress}`, "_blank");
|
||||
}}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View on DexScreener
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveToken?.(token.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
12
surfsense_web/components/crypto/index.ts
Normal file
12
surfsense_web/components/crypto/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { PriceDisplay } from "./PriceDisplay";
|
||||
export { SafetyBadge } from "./SafetyBadge";
|
||||
export { ChainIcon } from "./ChainIcon";
|
||||
export { MarketOverview } from "./MarketOverview";
|
||||
export { WatchlistTable } from "./WatchlistTable";
|
||||
export { AlertsPanel } from "./AlertsPanel";
|
||||
export { PortfolioSummary } from "./PortfolioSummary";
|
||||
|
||||
// Modal Components
|
||||
export { AddTokenModal } from "./AddTokenModal";
|
||||
export { CreateAlertModal, type AlertConfig } from "./CreateAlertModal";
|
||||
export { UserProfileSection, type UserProfile } from "./UserProfileSection";
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { Coins, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -241,6 +241,12 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Crypto",
|
||||
url: `/dashboard/${searchSpaceId}/crypto`,
|
||||
icon: Coins,
|
||||
isActive: pathname?.includes("/crypto"),
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||
);
|
||||
|
|
|
|||
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal file
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle, Star, Bell, Trash2, Eye, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for action confirmation tool arguments
|
||||
export const ActionConfirmationArgsSchema = z.object({
|
||||
actionType: z.enum(["watchlist_add", "watchlist_remove", "alert_set", "alert_delete"]),
|
||||
tokenSymbol: z.string(),
|
||||
details: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type ActionConfirmationArgs = z.infer<typeof ActionConfirmationArgsSchema>;
|
||||
|
||||
// Schema for action confirmation result
|
||||
export const ActionConfirmationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ActionConfirmationResult = z.infer<typeof ActionConfirmationResultSchema>;
|
||||
|
||||
const ACTION_CONFIG = {
|
||||
watchlist_add: {
|
||||
icon: Star,
|
||||
title: "Added to Watchlist",
|
||||
iconColor: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
watchlist_remove: {
|
||||
icon: Trash2,
|
||||
title: "Removed from Watchlist",
|
||||
iconColor: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
alert_set: {
|
||||
icon: Bell,
|
||||
title: "Alert Created",
|
||||
iconColor: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
alert_delete: {
|
||||
icon: Trash2,
|
||||
title: "Alert Deleted",
|
||||
iconColor: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ActionConfirmationToolUI - Shows confirmation when AI executes actions
|
||||
* Used for watchlist add/remove, alert set/delete confirmations
|
||||
*/
|
||||
export const ActionConfirmationToolUI = makeAssistantToolUI<ActionConfirmationArgs, ActionConfirmationResult>({
|
||||
toolName: "confirm_action",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const config = ACTION_CONFIG[args.actionType];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card className={cn("my-3 overflow-hidden border-l-4",
|
||||
args.actionType.includes("add") || args.actionType === "alert_set"
|
||||
? "border-l-green-500"
|
||||
: "border-l-red-500"
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn("p-2 rounded-full", config.bgColor)}>
|
||||
{isLoading ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4", config.iconColor)} />
|
||||
<span className="font-medium">{config.title}</span>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{args.tokenSymbol}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{args.details && args.details.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mb-1">Default monitoring enabled:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{args.details.map((detail, i) => (
|
||||
<li key={i}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result message */}
|
||||
{result?.message && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-4 ml-11">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
View Watchlist
|
||||
</Button>
|
||||
{(args.actionType === "watchlist_add" || args.actionType === "alert_set") && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Edit Alerts
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bell, TrendingUp, TrendingDown, Percent, DollarSign, Activity, Trash2, Edit2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// Schema for alert configuration
|
||||
const AlertConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["price_above", "price_below", "percent_change", "volume_spike", "whale_activity"]),
|
||||
value: z.number(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Schema for alert configuration tool arguments
|
||||
export const AlertConfigurationArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
tokenName: z.string().optional(),
|
||||
alerts: z.array(AlertConfigSchema),
|
||||
});
|
||||
|
||||
export type AlertConfigurationArgs = z.infer<typeof AlertConfigurationArgsSchema>;
|
||||
|
||||
// Schema for alert configuration result
|
||||
export const AlertConfigurationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AlertConfigurationResult = z.infer<typeof AlertConfigurationResultSchema>;
|
||||
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_above: { icon: TrendingUp, label: "Price Above", color: "text-green-500" },
|
||||
price_below: { icon: TrendingDown, label: "Price Below", color: "text-red-500" },
|
||||
percent_change: { icon: Percent, label: "% Change", color: "text-blue-500" },
|
||||
volume_spike: { icon: Activity, label: "Volume Spike", color: "text-purple-500" },
|
||||
whale_activity: { icon: DollarSign, label: "Whale Activity", color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const formatValue = (type: string, value: number): string => {
|
||||
if (type === "percent_change") return `${value > 0 ? "+" : ""}${value}%`;
|
||||
if (type === "volume_spike") return `${value}x normal`;
|
||||
if (type === "whale_activity") return `>${value.toLocaleString()} USD`;
|
||||
return `$${value < 1 ? value.toFixed(6) : value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* AlertConfigurationToolUI - Displays/edits alert configurations for a token
|
||||
* Used when AI responds to "set alert for BULLA" or "show my alerts for BULLA"
|
||||
*/
|
||||
export const AlertConfigurationToolUI = makeAssistantToolUI<AlertConfigurationArgs, AlertConfigurationResult>({
|
||||
toolName: "configure_alerts",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const alerts = args.alerts || [];
|
||||
const enabledCount = alerts.filter(a => a.enabled).length;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-blue-500" />
|
||||
Alerts for {args.tokenSymbol}
|
||||
<Badge variant="secondary">{enabledCount} active</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-4 w-4 mr-1" />
|
||||
Add Alert
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<Bell className="h-10 w-10 mx-auto mb-2 opacity-50" />
|
||||
<p>No alerts configured</p>
|
||||
<p className="text-sm">Say "Alert me if {args.tokenSymbol} drops 20%"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={alert.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<div>
|
||||
<p className="font-medium">{config.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatValue(alert.type, alert.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={alert.enabled} />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal file
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Users, AlertTriangle, Shield, Crown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for holder
|
||||
const HolderSchema = z.object({
|
||||
rank: z.number(),
|
||||
address: z.string(),
|
||||
label: z.string().optional(),
|
||||
balance: z.number(),
|
||||
percentage: z.number(),
|
||||
isContract: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema for holder analysis tool arguments
|
||||
export const HolderAnalysisArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
chain: z.string(),
|
||||
totalHolders: z.number(),
|
||||
top10Percentage: z.number(),
|
||||
top50Percentage: z.number().optional(),
|
||||
holders: z.array(HolderSchema),
|
||||
concentrationRisk: z.enum(["low", "medium", "high", "critical"]).optional(),
|
||||
});
|
||||
|
||||
export type HolderAnalysisArgs = z.infer<typeof HolderAnalysisArgsSchema>;
|
||||
|
||||
// Schema for holder analysis result
|
||||
export const HolderAnalysisResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type HolderAnalysisResult = z.infer<typeof HolderAnalysisResultSchema>;
|
||||
|
||||
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";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* HolderAnalysisToolUI - Displays holder distribution inline in chat
|
||||
* Used when AI responds to "who holds BULLA?" or "analyze holders"
|
||||
*/
|
||||
export const HolderAnalysisToolUI = makeAssistantToolUI<HolderAnalysisArgs, HolderAnalysisResult>({
|
||||
toolName: "analyze_holders",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const holders = args.holders || [];
|
||||
const risk = args.concentrationRisk || "medium";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
Holder Analysis - {args.tokenSymbol}
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<ChainIcon chain={args.chain} size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium">{args.totalHolders.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn("rounded-lg p-3", args.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", args.top10Percentage > 50 && "text-red-500")}>{args.top10Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
{args.top50Percentage && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium">{args.top50Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("rounded-lg p-3", getRiskColor(risk))}>
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium 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-sm bg-yellow-500/10 rounded-lg p-3">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>High holder concentration detected. Top wallets could significantly impact price.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Holders List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Top Holders</p>
|
||||
<div className="divide-y max-h-[250px] overflow-y-auto">
|
||||
{holders.slice(0, 10).map((holder) => (
|
||||
<div key={holder.address} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-6">#{holder.rank}</span>
|
||||
{holder.rank <= 3 && <Crown className={cn("h-4 w-4", holder.rank === 1 ? "text-yellow-500" : holder.rank === 2 ? "text-gray-400" : "text-amber-600")} />}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{holder.label || shortenAddress(holder.address)}</p>
|
||||
{holder.isContract && <Badge variant="outline" className="text-xs">Contract</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBalance(holder.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal file
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BarChart3, TrendingUp, TrendingDown, Globe } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for market token
|
||||
const MarketTokenSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
marketCap: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for market overview tool arguments
|
||||
export const MarketOverviewArgsSchema = z.object({
|
||||
tokens: z.array(MarketTokenSchema),
|
||||
totalMarketCap: z.number().optional(),
|
||||
totalVolume24h: z.number().optional(),
|
||||
btcDominance: z.number().optional(),
|
||||
fearGreedIndex: z.number().optional(),
|
||||
});
|
||||
|
||||
export type MarketOverviewArgs = z.infer<typeof MarketOverviewArgsSchema>;
|
||||
|
||||
// Schema for market overview result
|
||||
export const MarketOverviewResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MarketOverviewResult = z.infer<typeof MarketOverviewResultSchema>;
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* MarketOverviewToolUI - Displays market overview inline in chat
|
||||
* Used when AI responds to "show market overview" or "how's the market?"
|
||||
*/
|
||||
export const MarketOverviewToolUI = makeAssistantToolUI<MarketOverviewArgs, MarketOverviewResult>({
|
||||
toolName: "get_market_overview",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
Market Overview
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Global Stats */}
|
||||
{(args.totalMarketCap || args.btcDominance || args.fearGreedIndex) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{args.totalMarketCap && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.totalMarketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.totalVolume24h && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.totalVolume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.btcDominance && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium">{args.btcDominance.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{args.fearGreedIndex && (
|
||||
<div className={cn("rounded-lg p-3", args.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", args.fearGreedIndex > 50 ? "text-green-500" : "text-red-500")}>
|
||||
{args.fearGreedIndex} - {args.fearGreedIndex > 75 ? "Extreme Greed" : args.fearGreedIndex > 50 ? "Greed" : args.fearGreedIndex > 25 ? "Fear" : "Extreme Fear"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Prices */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.symbol} className="bg-muted/50 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-lg">{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-sm 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Wallet, TrendingUp, TrendingDown, PieChart } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for portfolio holding
|
||||
const HoldingSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
balance: z.number(),
|
||||
value: z.number(),
|
||||
costBasis: z.number().optional(),
|
||||
pnl: z.number().optional(),
|
||||
pnlPercent: z.number().optional(),
|
||||
allocation: z.number(),
|
||||
});
|
||||
|
||||
// Schema for portfolio display tool arguments
|
||||
export const PortfolioDisplayArgsSchema = z.object({
|
||||
holdings: z.array(HoldingSchema),
|
||||
totalValue: z.number(),
|
||||
totalPnl: z.number().optional(),
|
||||
totalPnlPercent: z.number().optional(),
|
||||
lastUpdated: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PortfolioDisplayArgs = z.infer<typeof PortfolioDisplayArgsSchema>;
|
||||
|
||||
// Schema for portfolio display result
|
||||
export const PortfolioDisplayResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PortfolioDisplayResult = z.infer<typeof PortfolioDisplayResultSchema>;
|
||||
|
||||
const formatValue = (value: number): string => {
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* PortfolioDisplayToolUI - Displays user's portfolio inline in chat
|
||||
* Used when AI responds to "how's my portfolio?" or "show my holdings"
|
||||
*/
|
||||
export const PortfolioDisplayToolUI = makeAssistantToolUI<PortfolioDisplayArgs, PortfolioDisplayResult>({
|
||||
toolName: "get_portfolio",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const holdings = args.holdings || [];
|
||||
const hasPnl = args.totalPnl !== undefined;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-emerald-500" />
|
||||
Your Portfolio
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
{args.lastUpdated && (
|
||||
<span className="text-xs text-muted-foreground">Updated {args.lastUpdated}</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Total Value */}
|
||||
<div className="bg-gradient-to-r from-emerald-500/10 to-blue-500/10 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Value</p>
|
||||
<p className="text-3xl font-bold">{formatValue(args.totalValue)}</p>
|
||||
{hasPnl && (
|
||||
<p className={cn("text-sm flex items-center gap-1 mt-1", (args.totalPnl || 0) >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{(args.totalPnl || 0) >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{(args.totalPnl || 0) >= 0 ? "+" : ""}{formatValue(args.totalPnl || 0)} ({(args.totalPnlPercent || 0) >= 0 ? "+" : ""}{(args.totalPnlPercent || 0).toFixed(2)}%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holdings List */}
|
||||
{holdings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">No holdings found</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{holdings.map((holding) => (
|
||||
<div key={holding.symbol} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={holding.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{holding.symbol}</span>
|
||||
<Badge variant="secondary" className="text-xs">{holding.allocation.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{holding.balance.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatValue(holding.value)}</p>
|
||||
{holding.pnlPercent !== undefined && (
|
||||
<p className={cn("text-sm", holding.pnlPercent >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{holding.pnlPercent >= 0 ? "+" : ""}{holding.pnlPercent.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal file
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangle, TrendingUp, TrendingDown, Activity, Zap, Eye, Bell, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for proactive alert tool arguments
|
||||
export const ProactiveAlertArgsSchema = z.object({
|
||||
alertType: z.enum(["price_surge", "price_drop", "whale_buy", "whale_sell", "volume_spike", "safety_warning"]),
|
||||
tokenSymbol: z.string(),
|
||||
tokenName: z.string().optional(),
|
||||
value: z.number(),
|
||||
previousValue: z.number().optional(),
|
||||
message: z.string(),
|
||||
severity: z.enum(["info", "warning", "critical"]).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ProactiveAlertArgs = z.infer<typeof ProactiveAlertArgsSchema>;
|
||||
|
||||
// Schema for proactive alert result
|
||||
export const ProactiveAlertResultSchema = z.object({
|
||||
acknowledged: z.boolean(),
|
||||
});
|
||||
|
||||
export type ProactiveAlertResult = z.infer<typeof ProactiveAlertResultSchema>;
|
||||
|
||||
const ALERT_TYPE_CONFIG = {
|
||||
price_surge: { icon: TrendingUp, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
|
||||
price_drop: { icon: TrendingDown, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
|
||||
whale_buy: { icon: Zap, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
|
||||
whale_sell: { icon: Zap, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
|
||||
volume_spike: { icon: Activity, color: "text-purple-500", bgColor: "bg-purple-500/10", borderColor: "border-l-purple-500" },
|
||||
safety_warning: { icon: AlertTriangle, color: "text-yellow-500", bgColor: "bg-yellow-500/10", borderColor: "border-l-yellow-500" },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
info: { badge: "secondary", pulse: false },
|
||||
warning: { badge: "warning", pulse: false },
|
||||
critical: { badge: "destructive", pulse: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* ProactiveAlertToolUI - Displays AI-initiated alerts in chat
|
||||
* Used when AI proactively notifies user about price changes, whale activity, etc.
|
||||
*/
|
||||
export const ProactiveAlertToolUI = makeAssistantToolUI<ProactiveAlertArgs, ProactiveAlertResult>({
|
||||
toolName: "proactive_alert",
|
||||
render: ({ args, result }) => {
|
||||
const config = ALERT_TYPE_CONFIG[args.alertType];
|
||||
const severity = args.severity || "info";
|
||||
const severityConfig = SEVERITY_CONFIG[severity];
|
||||
const Icon = config.icon;
|
||||
const isAcknowledged = result?.acknowledged;
|
||||
|
||||
const formatChange = () => {
|
||||
if (args.previousValue === undefined) return null;
|
||||
const change = ((args.value - args.previousValue) / args.previousValue) * 100;
|
||||
return change;
|
||||
};
|
||||
|
||||
const change = formatChange();
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"my-3 overflow-hidden border-l-4 transition-all",
|
||||
config.borderColor,
|
||||
isAcknowledged && "opacity-60"
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Alert Icon */}
|
||||
<div className={cn(
|
||||
"p-2 rounded-full",
|
||||
config.bgColor,
|
||||
severityConfig.pulse && "animate-pulse"
|
||||
)}>
|
||||
<Icon className={cn("h-5 w-5", config.color)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={severityConfig.badge as any} className="uppercase text-xs">
|
||||
{args.alertType.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="font-bold">{args.tokenSymbol}</span>
|
||||
{change !== null && (
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
change >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{change >= 0 ? "+" : ""}{change.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{args.timestamp && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{args.timestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm">{args.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Dismiss */}
|
||||
{!isAcknowledged && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isAcknowledged && (
|
||||
<div className="flex gap-2 mt-3 ml-11">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
Adjust Alert
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal file
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, Star, Bell, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
import { SafetyBadge } from "@/components/crypto/SafetyBadge";
|
||||
|
||||
// Schema for token analysis tool arguments
|
||||
export const TokenAnalysisArgsSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string().optional(),
|
||||
chain: z.string(),
|
||||
contractAddress: z.string().optional(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
marketCap: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
liquidity: z.number().optional(),
|
||||
safetyScore: z.number().optional(),
|
||||
holderCount: z.number().optional(),
|
||||
top10HolderPercent: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TokenAnalysisArgs = z.infer<typeof TokenAnalysisArgsSchema>;
|
||||
|
||||
// Schema for token analysis result
|
||||
export const TokenAnalysisResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
isInWatchlist: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TokenAnalysisResult = z.infer<typeof TokenAnalysisResultSchema>;
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TokenAnalysisToolUI - Displays comprehensive token analysis in chat
|
||||
* Used when AI responds to token research queries like "analyze BULLA" or "is BULLA safe?"
|
||||
*/
|
||||
export const TokenAnalysisToolUI = makeAssistantToolUI<TokenAnalysisArgs, TokenAnalysisResult>({
|
||||
toolName: "analyze_token",
|
||||
render: ({ args, result, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const isInWatchlist = result?.isInWatchlist ?? false;
|
||||
|
||||
const handleOpenDexScreener = () => {
|
||||
if (args.contractAddress) {
|
||||
window.open(`https://dexscreener.com/${args.chain}/${args.contractAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
Token Analysis
|
||||
{isLoading && <Badge variant="secondary" className="animate-pulse">Analyzing...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={args.chain} size="md" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{args.symbol}</span>
|
||||
{args.name && <span className="text-muted-foreground text-sm">{args.name}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatPrice(args.price)}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
args.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{args.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{args.priceChange24h >= 0 ? "+" : ""}{args.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{args.safetyScore !== undefined && (
|
||||
<SafetyBadge score={args.safetyScore} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{args.marketCap && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.marketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.volume24h && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.liquidity && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{formatLargeNumber(args.liquidity)}</p>
|
||||
</div>
|
||||
)}
|
||||
{args.holderCount && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Holders</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{args.holderCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holder Concentration Warning */}
|
||||
{args.top10HolderPercent && args.top10HolderPercent > 50 && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Top 10 holders own {args.top10HolderPercent}% of supply - high concentration risk</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
<Star className={cn("h-4 w-4 mr-2", isInWatchlist && "fill-yellow-500 text-yellow-500")} />
|
||||
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal file
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flame, TrendingUp, TrendingDown, Star, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for trending token
|
||||
const TrendingTokenSchema = z.object({
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
contractAddress: z.string().optional(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
priceChange1h: z.number().optional(),
|
||||
volume24h: z.number().optional(),
|
||||
liquidity: z.number().optional(),
|
||||
rank: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for trending tokens tool arguments
|
||||
export const TrendingTokensArgsSchema = z.object({
|
||||
chain: z.string().optional(),
|
||||
tokens: z.array(TrendingTokenSchema),
|
||||
timeframe: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TrendingTokensArgs = z.infer<typeof TrendingTokensArgsSchema>;
|
||||
|
||||
// Schema for trending tokens result
|
||||
export const TrendingTokensResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TrendingTokensResult = z.infer<typeof TrendingTokensResultSchema>;
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TrendingTokensToolUI - Displays trending/hot tokens inline in chat
|
||||
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
|
||||
*/
|
||||
export const TrendingTokensToolUI = makeAssistantToolUI<TrendingTokensArgs, TrendingTokensResult>({
|
||||
toolName: "get_trending_tokens",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
const chain = args.chain || "all chains";
|
||||
const timeframe = args.timeframe || "24h";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
Trending on {chain}
|
||||
<Badge variant="secondary">{timeframe}</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">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-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted-foreground w-6">#{token.rank || index + 1}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn("text-sm 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 md:block">
|
||||
<p className="text-xs text-muted-foreground">Volume</p>
|
||||
<p className="text-sm">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal file
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { User, Shield, Target, Clock, Zap } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Schema for user profile tool arguments
|
||||
export const UserProfileArgsSchema = z.object({
|
||||
riskTolerance: z.enum(["conservative", "moderate", "aggressive"]),
|
||||
investmentStyle: z.enum(["day_trader", "swing", "long_term"]),
|
||||
preferredChains: z.array(z.string()),
|
||||
portfolioSizeRange: z.enum(["small", "medium", "large"]).optional(),
|
||||
experienceLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
notificationPreferences: z.object({
|
||||
priceAlerts: z.boolean(),
|
||||
whaleAlerts: z.boolean(),
|
||||
newsAlerts: z.boolean(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type UserProfileArgs = z.infer<typeof UserProfileArgsSchema>;
|
||||
|
||||
// Schema for user profile result
|
||||
export const UserProfileResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserProfileResult = z.infer<typeof UserProfileResultSchema>;
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
switch (risk) {
|
||||
case "conservative": return "text-green-500 bg-green-500/10 border-green-500/20";
|
||||
case "moderate": return "text-yellow-500 bg-yellow-500/10 border-yellow-500/20";
|
||||
case "aggressive": return "text-red-500 bg-red-500/10 border-red-500/20";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStyleIcon = (style: string) => {
|
||||
switch (style) {
|
||||
case "day_trader": return <Zap className="h-4 w-4" />;
|
||||
case "swing": return <Target className="h-4 w-4" />;
|
||||
case "long_term": return <Clock className="h-4 w-4" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatStyle = (style: string) => {
|
||||
switch (style) {
|
||||
case "day_trader": return "Day Trader";
|
||||
case "swing": return "Swing Trader";
|
||||
case "long_term": return "Long Term Investor";
|
||||
default: return style;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UserProfileToolUI - Displays user's investment profile inline in chat
|
||||
* Used when AI responds to "show my profile" or "what's my risk setting?"
|
||||
*/
|
||||
export const UserProfileToolUI = makeAssistantToolUI<UserProfileArgs, UserProfileResult>({
|
||||
toolName: "get_user_profile",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-indigo-500" />
|
||||
Your Investment Profile
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Main Profile Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Risk Tolerance */}
|
||||
<div className={cn("rounded-lg p-4 border", getRiskColor(args.riskTolerance))}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Risk Tolerance</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold capitalize">{args.riskTolerance}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{args.riskTolerance === "conservative" && "Prefer stable, lower-risk investments"}
|
||||
{args.riskTolerance === "moderate" && "Balance between risk and reward"}
|
||||
{args.riskTolerance === "aggressive" && "Willing to take higher risks for higher returns"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Investment Style */}
|
||||
<div className="rounded-lg p-4 border bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getStyleIcon(args.investmentStyle)}
|
||||
<span className="text-sm font-medium">Investment Style</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatStyle(args.investmentStyle)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{args.investmentStyle === "day_trader" && "Quick trades, high frequency"}
|
||||
{args.investmentStyle === "swing" && "Hold for days to weeks"}
|
||||
{args.investmentStyle === "long_term" && "Hold for months to years"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred Chains */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Preferred Chains</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{args.preferredChains.map((chain) => (
|
||||
<Badge key={chain} variant="secondary" className="capitalize">{chain}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Preferences */}
|
||||
{args.notificationPreferences && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Notifications</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{args.notificationPreferences.priceAlerts && <Badge variant="outline">Price Alerts</Badge>}
|
||||
{args.notificationPreferences.whaleAlerts && <Badge variant="outline">Whale Alerts</Badge>}
|
||||
{args.notificationPreferences.newsAlerts && <Badge variant="outline">News Alerts</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Hint */}
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
Say "update my risk tolerance to moderate" to change settings
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal file
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Star, TrendingUp, TrendingDown, Bell, Trash2, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for watchlist token
|
||||
const WatchlistTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
chain: z.string(),
|
||||
price: z.number(),
|
||||
priceChange24h: z.number(),
|
||||
alertCount: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for watchlist display tool arguments
|
||||
export const WatchlistDisplayArgsSchema = z.object({
|
||||
tokens: z.array(WatchlistTokenSchema),
|
||||
});
|
||||
|
||||
export type WatchlistDisplayArgs = z.infer<typeof WatchlistDisplayArgsSchema>;
|
||||
|
||||
// Schema for watchlist display result
|
||||
export const WatchlistDisplayResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WatchlistDisplayResult = z.infer<typeof WatchlistDisplayResultSchema>;
|
||||
|
||||
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 })}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* WatchlistDisplayToolUI - Displays user's watchlist inline in chat
|
||||
* Used when AI responds to "show my watchlist" or similar commands
|
||||
*/
|
||||
export const WatchlistDisplayToolUI = makeAssistantToolUI<WatchlistDisplayArgs, WatchlistDisplayResult>({
|
||||
toolName: "show_watchlist",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const tokens = args.tokens || [];
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
<Card className="my-3">
|
||||
<CardContent className="py-8 text-center">
|
||||
<Star className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">Your watchlist is empty</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Say "Add [token] to my watchlist" to start tracking
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Find best and worst performers
|
||||
const sortedByChange = [...tokens].sort((a, b) => b.priceChange24h - a.priceChange24h);
|
||||
const bestPerformer = sortedByChange[0];
|
||||
const worstPerformer = sortedByChange[sortedByChange.length - 1];
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
Your Watchlist
|
||||
<Badge variant="secondary">{tokens.length}</Badge>
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Token List */}
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.symbol}</span>
|
||||
{token.alertCount && token.alertCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
<Bell className="h-2.5 w-2.5 mr-0.5" />
|
||||
{token.alertCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-sm 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>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{tokens.length > 1 && (
|
||||
<div className="pt-3 border-t text-sm text-muted-foreground">
|
||||
<span className="text-green-500 font-medium">{bestPerformer.symbol}</span> is your best performer (+{bestPerformer.priceChange24h.toFixed(1)}%)
|
||||
{worstPerformer.priceChange24h < 0 && (
|
||||
<span> • <span className="text-red-500 font-medium">{worstPerformer.symbol}</span> needs attention ({worstPerformer.priceChange24h.toFixed(1)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal file
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Fish, ArrowUpRight, ArrowDownRight, ExternalLink, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "@/components/crypto/ChainIcon";
|
||||
|
||||
// Schema for whale transaction
|
||||
const WhaleTransactionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["buy", "sell", "transfer"]),
|
||||
amount: z.number(),
|
||||
amountUsd: z.number(),
|
||||
tokenSymbol: z.string(),
|
||||
walletAddress: z.string(),
|
||||
walletLabel: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
txHash: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema for whale activity tool arguments
|
||||
export const WhaleActivityArgsSchema = z.object({
|
||||
tokenSymbol: z.string(),
|
||||
chain: z.string(),
|
||||
transactions: z.array(WhaleTransactionSchema),
|
||||
summary: z.object({
|
||||
totalBuyVolume: z.number(),
|
||||
totalSellVolume: z.number(),
|
||||
netFlow: z.number(),
|
||||
uniqueWhales: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type WhaleActivityArgs = z.infer<typeof WhaleActivityArgsSchema>;
|
||||
|
||||
// Schema for whale activity result
|
||||
export const WhaleActivityResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WhaleActivityResult = z.infer<typeof WhaleActivityResultSchema>;
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const shortenAddress = (address: string): string => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string): string => {
|
||||
const diff = Date.now() - new Date(timestamp).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
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`;
|
||||
};
|
||||
|
||||
/**
|
||||
* WhaleActivityToolUI - Displays whale transactions inline in chat
|
||||
* Used when AI responds to "show whale activity for BULLA" or similar
|
||||
*/
|
||||
export const WhaleActivityToolUI = makeAssistantToolUI<WhaleActivityArgs, WhaleActivityResult>({
|
||||
toolName: "get_whale_activity",
|
||||
render: ({ args, status }) => {
|
||||
const isLoading = status.type === "running";
|
||||
const transactions = args.transactions || [];
|
||||
const summary = args.summary;
|
||||
|
||||
return (
|
||||
<Card className="my-3 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="h-5 w-5 text-blue-500" />
|
||||
Whale Activity - {args.tokenSymbol}
|
||||
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
|
||||
</div>
|
||||
<ChainIcon chain={args.chain} size="sm" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-green-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Buy Volume</p>
|
||||
<p className="font-medium text-green-500">{formatLargeNumber(summary.totalBuyVolume)}</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Sell Volume</p>
|
||||
<p className="font-medium text-red-500">{formatLargeNumber(summary.totalSellVolume)}</p>
|
||||
</div>
|
||||
<div className={cn("rounded-lg p-3", summary.netFlow >= 0 ? "bg-green-500/10" : "bg-red-500/10")}>
|
||||
<p className="text-xs text-muted-foreground">Net Flow</p>
|
||||
<p className={cn("font-medium", summary.netFlow >= 0 ? "text-green-500" : "text-red-500")}>
|
||||
{summary.netFlow >= 0 ? "+" : ""}{formatLargeNumber(summary.netFlow)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Unique Whales</p>
|
||||
<p className="font-medium">{summary.uniqueWhales}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction List */}
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">No whale transactions detected</p>
|
||||
) : (
|
||||
<div className="divide-y max-h-[300px] overflow-y-auto">
|
||||
{transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-full", tx.type === "buy" ? "bg-green-500/10" : tx.type === "sell" ? "bg-red-500/10" : "bg-muted")}>
|
||||
{tx.type === "buy" ? <ArrowUpRight className="h-4 w-4 text-green-500" /> : tx.type === "sell" ? <ArrowDownRight className="h-4 w-4 text-red-500" /> : <ArrowUpRight className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-medium capitalize", tx.type === "buy" ? "text-green-500" : tx.type === "sell" ? "text-red-500" : "")}>
|
||||
{tx.type}
|
||||
</span>
|
||||
<span className="font-medium">{formatLargeNumber(tx.amountUsd)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{tx.walletLabel || shortenAddress(tx.walletAddress)}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimeAgo(tx.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tx.txHash && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -91,3 +91,37 @@ export {
|
|||
SaveMemoryToolUI,
|
||||
} from "./user-memory";
|
||||
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
||||
|
||||
// Crypto Tool UI Components - Conversational Crypto Advisor
|
||||
export {
|
||||
// Token Analysis
|
||||
TokenAnalysisToolUI,
|
||||
TokenAnalysisArgsSchema,
|
||||
TokenAnalysisResultSchema,
|
||||
type TokenAnalysisArgs,
|
||||
type TokenAnalysisResult,
|
||||
// Watchlist Display
|
||||
WatchlistDisplayToolUI,
|
||||
WatchlistDisplayArgsSchema,
|
||||
WatchlistDisplayResultSchema,
|
||||
type WatchlistDisplayArgs,
|
||||
type WatchlistDisplayResult,
|
||||
// Action Confirmation
|
||||
ActionConfirmationToolUI,
|
||||
ActionConfirmationArgsSchema,
|
||||
ActionConfirmationResultSchema,
|
||||
type ActionConfirmationArgs,
|
||||
type ActionConfirmationResult,
|
||||
// Alert Configuration
|
||||
AlertConfigurationToolUI,
|
||||
AlertConfigurationArgsSchema,
|
||||
AlertConfigurationResultSchema,
|
||||
type AlertConfigurationArgs,
|
||||
type AlertConfigurationResult,
|
||||
// Proactive Alert
|
||||
ProactiveAlertToolUI,
|
||||
ProactiveAlertArgsSchema,
|
||||
ProactiveAlertResultSchema,
|
||||
type ProactiveAlertArgs,
|
||||
type ProactiveAlertResult,
|
||||
} from "./crypto";
|
||||
|
|
|
|||
467
surfsense_web/lib/mock/cryptoMockData.ts
Normal file
467
surfsense_web/lib/mock/cryptoMockData.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
/**
|
||||
* Mock data for Crypto Dashboard - SurfSense Web V2
|
||||
* Remove or disable in production
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export type ChainType = "solana" | "ethereum" | "base" | "arbitrum" | "polygon" | "bsc";
|
||||
|
||||
export interface TokenPrice {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
priceChange7d: number;
|
||||
volume24h: number;
|
||||
marketCap: number;
|
||||
chain: ChainType;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface WatchlistToken {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: ChainType;
|
||||
contractAddress: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
volume24h: number;
|
||||
marketCap: number;
|
||||
liquidity: number;
|
||||
safetyScore: number;
|
||||
hasAlerts: boolean;
|
||||
alertCount?: number;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
chain: ChainType;
|
||||
type: "price_above" | "price_below" | "price_change" | "volume" | "whale" | "liquidity" | "safety";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
isRead: boolean;
|
||||
severity: "info" | "warning" | "critical";
|
||||
}
|
||||
|
||||
export interface PortfolioToken {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: ChainType;
|
||||
amount: number;
|
||||
avgBuyPrice: number;
|
||||
currentPrice: number;
|
||||
value: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
allocation: number;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number;
|
||||
change24h: number;
|
||||
change24hPercent: number;
|
||||
tokens: PortfolioToken[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MARKET OVERVIEW DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_MARKET_PRICES: TokenPrice[] = [
|
||||
{
|
||||
symbol: "BTC",
|
||||
name: "Bitcoin",
|
||||
price: 97542.18,
|
||||
priceChange24h: 2.34,
|
||||
priceChange7d: 8.12,
|
||||
volume24h: 42_500_000_000,
|
||||
marketCap: 1_920_000_000_000,
|
||||
chain: "ethereum",
|
||||
icon: "₿",
|
||||
},
|
||||
{
|
||||
symbol: "ETH",
|
||||
name: "Ethereum",
|
||||
price: 3456.78,
|
||||
priceChange24h: -1.23,
|
||||
priceChange7d: 5.67,
|
||||
volume24h: 18_200_000_000,
|
||||
marketCap: 415_000_000_000,
|
||||
chain: "ethereum",
|
||||
icon: "Ξ",
|
||||
},
|
||||
{
|
||||
symbol: "SOL",
|
||||
name: "Solana",
|
||||
price: 198.45,
|
||||
priceChange24h: 5.67,
|
||||
priceChange7d: 12.34,
|
||||
volume24h: 4_500_000_000,
|
||||
marketCap: 92_000_000_000,
|
||||
chain: "solana",
|
||||
icon: "◎",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_TRENDING_TOKENS: TokenPrice[] = [
|
||||
{
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
priceChange7d: 342.5,
|
||||
volume24h: 1_200_000,
|
||||
marketCap: 2_100_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
price: 0.00002156,
|
||||
priceChange24h: 12.3,
|
||||
priceChange7d: 45.6,
|
||||
volume24h: 89_000_000,
|
||||
marketCap: 1_450_000_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
price: 2.45,
|
||||
priceChange24h: -5.2,
|
||||
priceChange7d: 23.4,
|
||||
volume24h: 245_000_000,
|
||||
marketCap: 2_450_000_000,
|
||||
chain: "solana",
|
||||
},
|
||||
{
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
price: 0.00001089,
|
||||
priceChange24h: 8.7,
|
||||
priceChange7d: -12.3,
|
||||
volume24h: 567_000_000,
|
||||
marketCap: 4_580_000_000,
|
||||
chain: "ethereum",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// WATCHLIST DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_WATCHLIST: WatchlistToken[] = [
|
||||
{
|
||||
id: "1",
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
price: 0.00001234,
|
||||
priceChange24h: 156.7,
|
||||
volume24h: 1_200_000,
|
||||
marketCap: 2_100_000,
|
||||
liquidity: 450_000,
|
||||
safetyScore: 72,
|
||||
hasAlerts: true,
|
||||
alertCount: 2,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
contractAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
||||
price: 0.00002156,
|
||||
priceChange24h: 12.3,
|
||||
volume24h: 89_000_000,
|
||||
marketCap: 1_450_000_000,
|
||||
liquidity: 45_000_000,
|
||||
safetyScore: 89,
|
||||
hasAlerts: true,
|
||||
alertCount: 1,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
contractAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
price: 2.45,
|
||||
priceChange24h: -5.2,
|
||||
volume24h: 245_000_000,
|
||||
marketCap: 2_450_000_000,
|
||||
liquidity: 125_000_000,
|
||||
safetyScore: 94,
|
||||
hasAlerts: false,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
price: 0.00001089,
|
||||
priceChange24h: 8.7,
|
||||
volume24h: 567_000_000,
|
||||
marketCap: 4_580_000_000,
|
||||
liquidity: 234_000_000,
|
||||
safetyScore: 85,
|
||||
hasAlerts: false,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
contractAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
|
||||
price: 0.0156,
|
||||
priceChange24h: -15.3,
|
||||
volume24h: 12_000_000,
|
||||
marketCap: 156_000_000,
|
||||
liquidity: 8_500_000,
|
||||
safetyScore: 78,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// ALERTS DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_ALERTS: Alert[] = [
|
||||
{
|
||||
id: "alert-1",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
type: "price_above",
|
||||
message: "BULLA price increased above $0.00001 (+156%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||
isRead: false,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
id: "alert-2",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
type: "whale",
|
||||
message: "Large transaction detected: 500M BULLA ($6,170) transferred",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15),
|
||||
isRead: false,
|
||||
severity: "warning",
|
||||
},
|
||||
{
|
||||
id: "alert-3",
|
||||
tokenSymbol: "DEGEN",
|
||||
tokenName: "Degen",
|
||||
chain: "base",
|
||||
type: "price_below",
|
||||
message: "DEGEN dropped below $0.02 (-15%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30),
|
||||
isRead: false,
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "alert-4",
|
||||
tokenSymbol: "BONK",
|
||||
tokenName: "Bonk",
|
||||
chain: "solana",
|
||||
type: "volume",
|
||||
message: "BONK volume spike: 3x average in last hour",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60),
|
||||
isRead: true,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
id: "alert-5",
|
||||
tokenSymbol: "DEGEN",
|
||||
tokenName: "Degen",
|
||||
chain: "base",
|
||||
type: "liquidity",
|
||||
message: "DEGEN liquidity decreased by 12% ($1.2M removed)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 120),
|
||||
isRead: true,
|
||||
severity: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// PORTFOLIO DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_PORTFOLIO: PortfolioSummary = {
|
||||
totalValue: 15_234.56,
|
||||
totalPnl: 3_456.78,
|
||||
totalPnlPercent: 29.34,
|
||||
change24h: 456.12,
|
||||
change24hPercent: 3.08,
|
||||
tokens: [
|
||||
{
|
||||
id: "p1",
|
||||
symbol: "SOL",
|
||||
name: "Solana",
|
||||
chain: "solana",
|
||||
amount: 25.5,
|
||||
avgBuyPrice: 145.00,
|
||||
currentPrice: 198.45,
|
||||
value: 5_060.48,
|
||||
pnl: 1_362.98,
|
||||
pnlPercent: 36.86,
|
||||
allocation: 33.2,
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
symbol: "ETH",
|
||||
name: "Ethereum",
|
||||
chain: "ethereum",
|
||||
amount: 1.2,
|
||||
avgBuyPrice: 2_800.00,
|
||||
currentPrice: 3_456.78,
|
||||
value: 4_148.14,
|
||||
pnl: 788.14,
|
||||
pnlPercent: 23.46,
|
||||
allocation: 27.2,
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
amount: 150_000_000,
|
||||
avgBuyPrice: 0.000015,
|
||||
currentPrice: 0.00002156,
|
||||
value: 3_234.00,
|
||||
pnl: 984.00,
|
||||
pnlPercent: 43.73,
|
||||
allocation: 21.2,
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
amount: 500,
|
||||
avgBuyPrice: 1.80,
|
||||
currentPrice: 2.45,
|
||||
value: 1_225.00,
|
||||
pnl: 325.00,
|
||||
pnlPercent: 36.11,
|
||||
allocation: 8.0,
|
||||
},
|
||||
{
|
||||
id: "p5",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
amount: 100_000_000,
|
||||
avgBuyPrice: 0.000012,
|
||||
currentPrice: 0.00001089,
|
||||
value: 1_089.00,
|
||||
pnl: -111.00,
|
||||
pnlPercent: -9.25,
|
||||
allocation: 7.2,
|
||||
},
|
||||
{
|
||||
id: "p6",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
amount: 30_000,
|
||||
avgBuyPrice: 0.012,
|
||||
currentPrice: 0.0156,
|
||||
value: 468.00,
|
||||
pnl: 108.00,
|
||||
pnlPercent: 30.00,
|
||||
allocation: 3.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
if (price >= 1000) {
|
||||
return `$${price.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
} else if (price >= 1) {
|
||||
return `$${price.toFixed(2)}`;
|
||||
} else if (price >= 0.0001) {
|
||||
return `$${price.toFixed(6)}`;
|
||||
} else {
|
||||
return `$${price.toFixed(10)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLargeNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) {
|
||||
return `$${(num / 1_000_000_000).toFixed(2)}B`;
|
||||
} else if (num >= 1_000_000) {
|
||||
return `$${(num / 1_000_000).toFixed(2)}M`;
|
||||
} else if (num >= 1_000) {
|
||||
return `$${(num / 1_000).toFixed(2)}K`;
|
||||
}
|
||||
return `$${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
const sign = percent >= 0 ? "+" : "";
|
||||
return `${sign}${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function getChainColor(chain: ChainType): string {
|
||||
const colors: Record<ChainType, string> = {
|
||||
solana: "#9945FF",
|
||||
ethereum: "#627EEA",
|
||||
base: "#0052FF",
|
||||
arbitrum: "#28A0F0",
|
||||
polygon: "#8247E5",
|
||||
bsc: "#F0B90B",
|
||||
};
|
||||
return colors[chain] || "#888888";
|
||||
}
|
||||
|
||||
export function getChainName(chain: ChainType): string {
|
||||
const names: Record<ChainType, string> = {
|
||||
solana: "Solana",
|
||||
ethereum: "Ethereum",
|
||||
base: "Base",
|
||||
arbitrum: "Arbitrum",
|
||||
polygon: "Polygon",
|
||||
bsc: "BNB Chain",
|
||||
};
|
||||
return names[chain] || chain;
|
||||
}
|
||||
|
||||
export function getSafetyColor(score: number): string {
|
||||
if (score >= 80) return "#22C55E"; // green
|
||||
if (score >= 60) return "#EAB308"; // yellow
|
||||
if (score >= 40) return "#F97316"; // orange
|
||||
return "#EF4444"; // red
|
||||
}
|
||||
|
||||
export function getSafetyLabel(score: number): string {
|
||||
if (score >= 80) return "Safe";
|
||||
if (score >= 60) return "Medium";
|
||||
if (score >= 40) return "Risky";
|
||||
return "Danger";
|
||||
}
|
||||
|
||||
4
surfsense_web/start-dev.sh
Executable file
4
surfsense_web/start-dev.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
cd /Users/mac_1/Documents/GitHub/SurfSense/surfsense_web
|
||||
pnpm dev
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue