Compare commits
6 commits
ff36952dc9
...
93aa749a04
| Author | SHA1 | Date | |
|---|---|---|---|
| 93aa749a04 | |||
| 6d18e8200f | |||
| a9e0df4442 | |||
| dd0903a1e0 | |||
| 518cc5a981 | |||
| bb96ba0b08 |
71 changed files with 8347 additions and 6376 deletions
1
.envrc
1
.envrc
|
|
@ -1,3 +1,2 @@
|
|||
source_up
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PWD/node_modules/.bin:$PATH"
|
||||
export VITE_API_URL="https://rugmunch.io"
|
||||
|
|
|
|||
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/*]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bunx tsc --noEmit
|
||||
|
||||
build:
|
||||
name: Build Check
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run build
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noExplicitAny": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noDocumentCookie": "error",
|
||||
"noAssignInExpressions": "error"
|
||||
|
|
|
|||
3271
pnpm-lock.yaml
generated
3271
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
// @ts-nocheck
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
|
@ -30,7 +31,7 @@ import {
|
|||
import { useEffect, useState } from 'react'
|
||||
import aiRouter from '@/services/aiRouter'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || ''
|
||||
const API_BASE = API_BASE_URL || ''
|
||||
|
||||
/* ── Types ── */
|
||||
interface NewsItem {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import cytoscape from 'cytoscape'
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error — cytoscape-fcose has no @types
|
||||
import fcose from 'cytoscape-fcose'
|
||||
import { Crosshair, Eye, Maximize2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
|
|
@ -70,7 +71,7 @@ export default function RugRiskPanel({ pair }: Props) {
|
|||
})
|
||||
|
||||
// Fetch trading signal
|
||||
fetch(`${import.meta.env.VITE_API_URL || ''}/api/ai-trading/signal`, {
|
||||
fetch(`${API_BASE_URL || ''}/api/ai-trading/signal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
176
src/components/community-forensics/CreateInvestigationModal.tsx
Normal file
176
src/components/community-forensics/CreateInvestigationModal.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function CreateInvestigationModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [targetAddress, setTargetAddress] = useState('')
|
||||
const [chain, setChain] = useState('solana')
|
||||
const [investigationType, setInvestigationType] = useState('wallet_trace')
|
||||
const [tags, setTags] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !description.trim() || !targetAddress.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await CommunityAPI.createInvestigation({
|
||||
title,
|
||||
description,
|
||||
target_address: targetAddress,
|
||||
chain,
|
||||
investigation_type: investigationType,
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create investigation')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass rounded-2xl p-6 border-purple-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-black text-white">New Investigation</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., CRM Token Rug Pull Trace"
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what you're investigating and why..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">Chain</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="solana">Solana</option>
|
||||
<option value="ethereum">Ethereum</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="bsc">BSC</option>
|
||||
<option value="arbitrum">Arbitrum</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
<option value="avalanche">Avalanche</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">Type</label>
|
||||
<select
|
||||
value={investigationType}
|
||||
onChange={(e) => setInvestigationType(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="wallet_trace">Wallet Trace</option>
|
||||
<option value="token_audit">Token Audit</option>
|
||||
<option value="rug_pull">Rug Pull</option>
|
||||
<option value="social_engineering">Social Engineering</option>
|
||||
<option value="bundle_detection">Bundle Detection</option>
|
||||
<option value="cross_chain">Cross-Chain</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Target Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={targetAddress}
|
||||
onChange={(e) => setTargetAddress(e.target.value)}
|
||||
placeholder="Token contract or wallet address..."
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="honeypot, dev-wallet, sniper..."
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-gray-400 hover:text-white transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !title.trim() || !description.trim() || !targetAddress.trim()}
|
||||
className="px-6 py-2 bg-gradient-to-r from-purple-600 to-purple-500 text-white text-sm font-semibold rounded-xl hover:from-purple-500 hover:to-purple-400 transition-all disabled:opacity-40 flex items-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" /> Create Investigation
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
261
src/components/community-forensics/InvestigationDetailModal.tsx
Normal file
261
src/components/community-forensics/InvestigationDetailModal.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useState } from 'react'
|
||||
import { Database, ExternalLink, Globe, Plus, ThumbsUp, X } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function InvestigationDetailModal({ investigation, onClose }: { investigation: Investigation; onClose: () => void }) {
|
||||
const [newEvidence, setNewEvidence] = useState('')
|
||||
const [evidenceType, setEvidenceType] = useState('note')
|
||||
const [evidenceTitle, setEvidenceTitle] = useState('')
|
||||
const [evidenceSeverity, setEvidenceSeverity] = useState<'info' | 'warning' | 'critical'>('info')
|
||||
const [addingEvidence, setAddingEvidence] = useState(false)
|
||||
|
||||
const handleAddEvidence = async () => {
|
||||
if (!newEvidence.trim() || !evidenceTitle.trim()) return
|
||||
setAddingEvidence(true)
|
||||
try {
|
||||
await CommunityAPI.addEvidence(investigation.id, {
|
||||
evidence_type: evidenceType,
|
||||
title: evidenceTitle,
|
||||
content: newEvidence,
|
||||
severity: evidenceSeverity,
|
||||
})
|
||||
// Reload would happen here in full implementation
|
||||
setNewEvidence('')
|
||||
setEvidenceTitle('')
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to add evidence')
|
||||
} finally {
|
||||
setAddingEvidence(false)
|
||||
}
|
||||
}
|
||||
|
||||
const evidenceIcons: Record<string, any> = {
|
||||
transaction: Activity,
|
||||
screenshot: Image,
|
||||
contract_code: FileCode,
|
||||
social_post: MessageSquare,
|
||||
graph: Network,
|
||||
note: StickyNote,
|
||||
external_link: Link2,
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass rounded-2xl p-6 border-purple-500/20 w-full max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${CHAIN_COLORS[investigation.chain] || '#666'}22` }}
|
||||
>
|
||||
<Globe className="w-5 h-5" style={{ color: CHAIN_COLORS[investigation.chain] || '#666' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-white">{investigation.title}</h2>
|
||||
<p className="text-xs text-gray-500 font-mono">{investigation.target_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor: `${STATUS_CONFIG[investigation.status]?.color}22`,
|
||||
color: STATUS_CONFIG[investigation.status]?.color,
|
||||
}}
|
||||
>
|
||||
{STATUS_CONFIG[investigation.status]?.label || investigation.status}
|
||||
</div>
|
||||
{investigation.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 rounded-full bg-white/5 text-[10px] text-gray-400 border border-white/5"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-6">{investigation.description}</p>
|
||||
|
||||
{/* Evidence Section */}
|
||||
<div className="border-t border-white/5 pt-4 mb-4">
|
||||
<h3 className="text-sm font-bold text-white mb-3 flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
Evidence ({investigation.evidence?.length || 0})
|
||||
</h3>
|
||||
|
||||
{investigation.evidence && investigation.evidence.length > 0 ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{investigation.evidence.map((ev) => {
|
||||
const Icon = evidenceIcons[ev.evidence_type] || StickyNote
|
||||
return (
|
||||
<div key={ev.id} className="glass rounded-lg p-3 border-white/5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-white">{ev.title}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ev.severity === 'critical'
|
||||
? '#ef444422'
|
||||
: ev.severity === 'warning'
|
||||
? '#f59e0b22'
|
||||
: '#22c55e22',
|
||||
color:
|
||||
ev.severity === 'critical'
|
||||
? '#ef4444'
|
||||
: ev.severity === 'warning'
|
||||
? '#f59e0b'
|
||||
: '#22c55e',
|
||||
}}
|
||||
>
|
||||
{ev.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{ev.content}</p>
|
||||
{ev.source_url && (
|
||||
<a
|
||||
href={ev.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1 mt-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" /> Source
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mb-4">No evidence added yet.</p>
|
||||
)}
|
||||
|
||||
{/* Add Evidence Form */}
|
||||
<div className="glass rounded-xl p-4 border-purple-500/10">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Add Evidence</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={evidenceType}
|
||||
onChange={(e) => setEvidenceType(e.target.value)}
|
||||
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-xs focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="note">Note</option>
|
||||
<option value="transaction">Transaction</option>
|
||||
<option value="screenshot">Screenshot</option>
|
||||
<option value="contract_code">Contract Code</option>
|
||||
<option value="social_post">Social Post</option>
|
||||
<option value="graph">Graph</option>
|
||||
<option value="external_link">External Link</option>
|
||||
</select>
|
||||
<select
|
||||
value={evidenceSeverity}
|
||||
onChange={(e) => setEvidenceSeverity(e.target.value as any)}
|
||||
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-xs focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceTitle}
|
||||
onChange={(e) => setEvidenceTitle(e.target.value)}
|
||||
placeholder="Evidence title..."
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-xs placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50"
|
||||
/>
|
||||
<textarea
|
||||
value={newEvidence}
|
||||
onChange={(e) => setNewEvidence(e.target.value)}
|
||||
placeholder="Describe your evidence..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-xs placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50 resize-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEvidence}
|
||||
disabled={addingEvidence || !newEvidence.trim() || !evidenceTitle.trim()}
|
||||
className="w-full px-4 py-2 bg-purple-500/10 border border-purple-500/20 rounded-lg text-xs text-purple-400 hover:bg-purple-500/20 transition-all disabled:opacity-40 flex items-center justify-center gap-2"
|
||||
>
|
||||
{addingEvidence ? (
|
||||
<>
|
||||
<div className="animate-spin w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3 h-3" /> Add Evidence
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 border-t border-white/5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await CommunityAPI.upvoteInvestigation(investigation.id)
|
||||
alert('Upvoted!')
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-gray-400 hover:text-white hover:border-white/20 transition-all"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4" /> Upvote
|
||||
</button>
|
||||
{investigation.status !== 'submitted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await CommunityAPI.submitInvestigation(investigation.id)
|
||||
alert('Investigation submitted for review!')
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-500/10 border border-purple-500/20 rounded-xl text-sm text-purple-400 hover:bg-purple-500/20 transition-all"
|
||||
>
|
||||
<Upload className="w-4 h-4" /> Submit for Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Wallet Trace Tab — Connect-the-dots graph ── */
|
||||
192
src/components/community-forensics/InvestigationsTab.tsx
Normal file
192
src/components/community-forensics/InvestigationsTab.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Database, Eye, Globe, Plus, ThumbsUp } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function InvestigationsTab({
|
||||
investigations,
|
||||
viewMode,
|
||||
chainFilter,
|
||||
setChainFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
onCreate,
|
||||
onSelect,
|
||||
}: {
|
||||
investigations: Investigation[]
|
||||
viewMode: ViewMode
|
||||
chainFilter: string
|
||||
setChainFilter: (v: string) => void
|
||||
statusFilter: string
|
||||
setStatusFilter: (v: string) => void
|
||||
onCreate: () => void
|
||||
onSelect: (inv: Investigation) => void
|
||||
}) {
|
||||
const filtered = investigations.filter((inv) => {
|
||||
if (chainFilter !== 'all' && inv.chain !== chainFilter) return false
|
||||
if (statusFilter !== 'all' && inv.status !== statusFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={chainFilter}
|
||||
onChange={(e) => setChainFilter(e.target.value)}
|
||||
className="px-3 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-white focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="all">All Chains</option>
|
||||
<option value="solana">Solana</option>
|
||||
<option value="ethereum">Ethereum</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="bsc">BSC</option>
|
||||
<option value="arbitrum">Arbitrum</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-white focus:outline-none focus:border-purple-500/50"
|
||||
style={{ backgroundColor: '#0e0e16' }}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-purple-500 text-white text-sm font-semibold rounded-xl hover:from-purple-500 hover:to-purple-400 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Investigation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<FolderOpen className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No investigations yet. Be the first sleuth!</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="mt-4 px-6 py-2 bg-purple-500/20 text-purple-400 rounded-xl text-sm hover:bg-purple-500/30 transition-all"
|
||||
>
|
||||
Start Investigation
|
||||
</button>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((inv, i) => (
|
||||
<motion.div
|
||||
key={inv.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => onSelect(inv)}
|
||||
className="glass rounded-xl p-5 border-white/5 hover:border-purple-500/20 cursor-pointer transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${CHAIN_COLORS[inv.chain] || '#666'}22` }}
|
||||
>
|
||||
<Globe className="w-4 h-4" style={{ color: CHAIN_COLORS[inv.chain] || '#666' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">{inv.chain.toUpperCase()}</div>
|
||||
<div className="text-[10px] text-gray-500 font-mono">
|
||||
{inv.target_address.slice(0, 8)}...{inv.target_address.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold flex items-center gap-1"
|
||||
style={{
|
||||
backgroundColor: `${STATUS_CONFIG[inv.status]?.color}22`,
|
||||
color: STATUS_CONFIG[inv.status]?.color,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const StatusIcon = STATUS_CONFIG[inv.status]?.icon
|
||||
return StatusIcon ? <StatusIcon className="w-3 h-3" /> : null
|
||||
})()}
|
||||
{STATUS_CONFIG[inv.status]?.label || inv.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-white mb-1 group-hover:text-purple-400 transition-colors">
|
||||
{inv.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{inv.description}</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {inv.views}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3" /> {inv.upvotes}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" /> {inv.evidence?.length || 0} evidence
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{inv.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{inv.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 rounded-full bg-white/5 text-[10px] text-gray-400 border border-white/5"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((inv, i) => (
|
||||
<motion.div
|
||||
key={inv.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => onSelect(inv)}
|
||||
className="glass rounded-xl p-4 border-white/5 hover:border-purple-500/20 cursor-pointer transition-all flex items-center gap-4"
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${CHAIN_COLORS[inv.chain] || '#666'}22` }}
|
||||
>
|
||||
<Globe className="w-5 h-5" style={{ color: CHAIN_COLORS[inv.chain] || '#666' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-white">{inv.title}</h3>
|
||||
<p className="text-xs text-gray-500 truncate">{inv.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 shrink-0">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3.5 h-3.5" /> {inv.views}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3.5 h-3.5" /> {inv.upvotes}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="w-3.5 h-3.5" /> {inv.evidence?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
65
src/components/community-forensics/LeaderboardTab.tsx
Normal file
65
src/components/community-forensics/LeaderboardTab.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { motion } from 'framer-motion'
|
||||
|
||||
export function LeaderboardTab({ sleuths }: { sleuths: Sleuth[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-white mb-2">Community Leaderboard</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Top sleuths ranked by reputation. Earn points by submitting investigations, adding evidence, and getting
|
||||
reports verified.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-xl border-white/5 overflow-hidden">
|
||||
<div className="grid grid-cols-12 gap-4 p-4 text-xs font-bold text-gray-500 uppercase tracking-wider border-b border-white/5">
|
||||
<div className="col-span-1">Rank</div>
|
||||
<div className="col-span-3">Sleuth</div>
|
||||
<div className="col-span-2 text-right">Reputation</div>
|
||||
<div className="col-span-2 text-right">Investigations</div>
|
||||
<div className="col-span-2 text-right">Reports</div>
|
||||
<div className="col-span-2 text-right">Evidence</div>
|
||||
</div>
|
||||
|
||||
{sleuths.map((sleuth, i) => (
|
||||
<motion.div
|
||||
key={sleuth.user_id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="grid grid-cols-12 gap-4 p-4 items-center border-b border-white/5 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<div className="col-span-1">
|
||||
{i === 0 ? (
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
) : i === 1 ? (
|
||||
<Trophy className="w-5 h-5 text-gray-400" />
|
||||
) : i === 2 ? (
|
||||
<Trophy className="w-5 h-5 text-amber-600" />
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/10 flex items-center justify-center">
|
||||
<Fingerprint className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<span className="text-sm text-white font-mono">{sleuth.user_id.slice(0, 8)}...</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="text-sm font-bold text-purple-400">{sleuth.reputation}</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-sm text-gray-400">{sleuth.investigations}</div>
|
||||
<div className="col-span-2 text-right text-sm text-gray-400">
|
||||
{sleuth.reports_verified}/{sleuth.reports_submitted}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-sm text-gray-400">{sleuth.evidence_added}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Modals ───────────────────────────────────────────────────── */
|
||||
|
||||
81
src/components/community-forensics/NotebooksTab.tsx
Normal file
81
src/components/community-forensics/NotebooksTab.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Download } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function NotebooksTab({ notebooks }: { notebooks: NotebookTemplate[] }) {
|
||||
const difficultyColors = {
|
||||
beginner: '#22c55e',
|
||||
intermediate: '#f59e0b',
|
||||
advanced: '#ef4444',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-white mb-2">Jupyter Notebook Templates</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Pre-loaded Python scripts using Web3.py, Solana.py, and open-source tools. Download and run locally or in
|
||||
Google Colab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{notebooks.map((nb, i) => (
|
||||
<motion.div
|
||||
key={nb.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="glass rounded-xl p-5 border-white/5 hover:border-purple-500/20 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center">
|
||||
<FlaskConical className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor: `${difficultyColors[nb.difficulty] || '#666'}22`,
|
||||
color: difficultyColors[nb.difficulty] || '#666',
|
||||
}}
|
||||
>
|
||||
{nb.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-white mb-1">{nb.name}</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">{nb.description}</p>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor: `${CHAIN_COLORS[nb.chain] || '#666'}22`,
|
||||
color: CHAIN_COLORS[nb.chain] || '#666',
|
||||
}}
|
||||
>
|
||||
{nb.chain.toUpperCase()}
|
||||
</span>
|
||||
{nb.tools.slice(0, 3).map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="px-2 py-0.5 rounded bg-white/5 text-[10px] text-gray-400 border border-white/5"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={nb.download_url}
|
||||
download
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 bg-purple-500/10 border border-purple-500/20 rounded-lg text-sm text-purple-400 hover:bg-purple-500/20 transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Download .ipynb
|
||||
</a>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
122
src/components/community-forensics/ReportsTab.tsx
Normal file
122
src/components/community-forensics/ReportsTab.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Award, Eye, FileText, ThumbsUp } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function ReportsTab({
|
||||
reports,
|
||||
viewMode,
|
||||
onSelect,
|
||||
}: {
|
||||
reports: CommunityReport[]
|
||||
viewMode: ViewMode
|
||||
onSelect: (r: CommunityReport) => void
|
||||
}) {
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No reports submitted yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{reports.map((report, i) => {
|
||||
const statusCfg = REPORT_STATUS_CONFIG[report.status]
|
||||
return (
|
||||
<motion.div
|
||||
key={report.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => onSelect(report)}
|
||||
className="glass rounded-xl p-5 border-white/5 hover:border-purple-500/20 cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold flex items-center gap-1"
|
||||
style={{ backgroundColor: `${statusCfg?.color}22`, color: statusCfg?.color }}
|
||||
>
|
||||
{statusCfg?.icon && <statusCfg.icon className="w-3 h-3" />}
|
||||
{statusCfg?.label || report.status}
|
||||
</div>
|
||||
{report.bounty_reward > 0 && (
|
||||
<div className="px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/10 text-yellow-400 flex items-center gap-1">
|
||||
<Award className="w-3 h-3" /> +{report.bounty_reward}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-white mb-1">{report.title}</h3>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{report.summary}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-20 h-1.5 rounded-full bg-white/5 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${report.risk_score}%`,
|
||||
backgroundColor:
|
||||
report.risk_score > 70 ? '#ef4444' : report.risk_score > 40 ? '#f59e0b' : '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 ml-1">{report.risk_score}/100</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {report.views}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3" /> {report.upvotes}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reports.map((report, i) => {
|
||||
const statusCfg = REPORT_STATUS_CONFIG[report.status]
|
||||
return (
|
||||
<motion.div
|
||||
key={report.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => onSelect(report)}
|
||||
className="glass rounded-xl p-4 border-white/5 hover:border-purple-500/20 cursor-pointer transition-all flex items-center gap-4"
|
||||
>
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold shrink-0"
|
||||
style={{ backgroundColor: `${statusCfg?.color}22`, color: statusCfg?.color }}
|
||||
>
|
||||
{statusCfg?.label || report.status}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-white">{report.title}</h3>
|
||||
<p className="text-xs text-gray-500 truncate">{report.summary}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 shrink-0">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3.5 h-3.5" /> {report.views}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3.5 h-3.5" /> {report.upvotes}
|
||||
</span>
|
||||
{report.bounty_reward > 0 && (
|
||||
<span className="flex items-center gap-1 text-yellow-400">
|
||||
<Award className="w-3.5 h-3.5" /> +{report.bounty_reward}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
86
src/components/community-forensics/ToolsTab.tsx
Normal file
86
src/components/community-forensics/ToolsTab.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Code, ExternalLink } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function ToolsTab({ tools }: { tools: OpenSourceTool[] }) {
|
||||
const categories = [...new Set(tools.map((t) => t.category))]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-white mb-2">Open-Source Forensics Tools</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Free tools you can use for your own investigations. Self-hosted options marked.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
{category}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{tools
|
||||
.filter((t) => t.category === category)
|
||||
.map((tool, i) => (
|
||||
<motion.div
|
||||
key={tool.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="glass rounded-xl p-4 border-white/5 hover:border-purple-500/20 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-bold text-white">{tool.name}</h4>
|
||||
{tool.self_hosted && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
Self-Hosted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">{tool.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{tool.chains.map((chain) => (
|
||||
<span
|
||||
key={chain}
|
||||
className="px-1.5 py-0.5 rounded text-[10px]"
|
||||
style={{
|
||||
backgroundColor: `${CHAIN_COLORS[chain] || '#666'}22`,
|
||||
color: CHAIN_COLORS[chain] || '#666',
|
||||
}}
|
||||
>
|
||||
{chain}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={tool.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" /> Docs
|
||||
</a>
|
||||
{tool.github && (
|
||||
<a
|
||||
href={tool.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Code className="w-3 h-3" /> GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
477
src/components/community-forensics/TraceTab.tsx
Normal file
477
src/components/community-forensics/TraceTab.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import { useMemo, useRef, useState } from 'react'
|
||||
import { CheckCircle, Download, ExternalLink, FileText, Search, Target, X } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function TraceTab() {
|
||||
const [address, setAddress] = useState('')
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [depth, setDepth] = useState(2)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [graphData, setGraphData] = useState<{ nodes: any[]; edges: any[] } | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<any>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
const traceWallet = async () => {
|
||||
if (!address || address.length < 20) return
|
||||
setLoading(true)
|
||||
setGraphData(null)
|
||||
try {
|
||||
const result = await databus.fetch('wallet_labels', { address, chain, depth, min_value_usd: 100 })
|
||||
const data = result.data || {}
|
||||
setGraphData({ nodes: data.nodes || [], edges: data.edges || [] })
|
||||
} catch {
|
||||
setGraphData(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const layoutNodes = useMemo(() => {
|
||||
if (!graphData) return []
|
||||
const nodes = graphData.nodes.map((n: any, i: number) => ({
|
||||
...n,
|
||||
x: 400 + Math.cos(i * 2.4) * (n.type === 'target' ? 0 : 120 + (n.hop || 1) * 80),
|
||||
y: 250 + Math.sin(i * 2.4) * (n.type === 'target' ? 0 : 80 + (n.hop || 1) * 60),
|
||||
}))
|
||||
const target = nodes.find((n: any) => n.type === 'target')
|
||||
if (target) {
|
||||
target.x = 400
|
||||
target.y = 250
|
||||
}
|
||||
return nodes
|
||||
}, [graphData])
|
||||
|
||||
const nodeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'target':
|
||||
return '#FF3366'
|
||||
case 'cex':
|
||||
return '#22D3EE'
|
||||
case 'mixer':
|
||||
return '#8B5CF6'
|
||||
case 'contract':
|
||||
return '#F59E0B'
|
||||
default:
|
||||
return '#6366F1'
|
||||
}
|
||||
}
|
||||
|
||||
const CHAINS = [
|
||||
{ value: 'ethereum', label: 'Ethereum', icon: '\u27E0' },
|
||||
{ value: 'base', label: 'Base', icon: '\u2299' },
|
||||
{ value: 'bsc', label: 'BSC', icon: '\u2B21' },
|
||||
{ value: 'solana', label: 'Solana', icon: '\u25CE' },
|
||||
{ value: 'arbitrum', label: 'Arbitrum', icon: '\u25C8' },
|
||||
{ value: 'polygon', label: 'Polygon', icon: '\u25C6' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Target className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-[15px] font-bold text-[#F1F1F6]">Wallet Connection Tracer</h3>
|
||||
<span className="text-[10px] text-[#5C6080] ml-auto">Draw lines between wallets, find the scammer</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5C6080]" />
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Enter wallet address (0x...)"
|
||||
className="w-full pl-9 pr-3 py-2.5 bg-[#0B0E18] border border-[#1E2235] rounded-lg text-[13px] text-[#F1F1F6] placeholder-[#5C6080] focus:outline-none focus:border-purple-500/40"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="bg-[#0B0E18] border border-[#1E2235] rounded-lg px-3 py-2.5 text-[13px] text-[#F1F1F6] focus:outline-none focus:border-purple-500/40 min-w-[140px]"
|
||||
>
|
||||
{CHAINS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.icon} {c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={depth}
|
||||
onChange={(e) => setDepth(Number(e.target.value))}
|
||||
className="bg-[#0B0E18] border border-[#1E2235] rounded-lg px-3 py-2.5 text-[13px] text-[#F1F1F6] focus:outline-none focus:border-purple-500/40 min-w-[100px]"
|
||||
>
|
||||
<option value={1}>1 hop</option>
|
||||
<option value={2}>2 hops</option>
|
||||
<option value={3}>3 hops</option>
|
||||
<option value={4}>4 hops</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={traceWallet}
|
||||
disabled={loading || address.length < 20}
|
||||
className="btn btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Target className="w-4 h-4" />} Trace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{graphData && (
|
||||
<div className="glass-card rounded-xl overflow-hidden">
|
||||
<div className="p-3 border-b border-[rgba(139,92,246,0.06)] flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-[13px] font-bold text-[#F1F1F6]">{layoutNodes.length} wallets</span>
|
||||
<span className="text-[11px] text-[#5C6080]">{graphData.edges.length} transfers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{[
|
||||
{ t: 'target', l: 'Target', c: '#FF3366' },
|
||||
{ t: 'cex', l: 'CEX', c: '#22D3EE' },
|
||||
{ t: 'mixer', l: 'Mixer', c: '#8B5CF6' },
|
||||
{ t: 'contract', l: 'Contract', c: '#F59E0B' },
|
||||
{ t: 'wallet', l: 'Wallet', c: '#6366F1' },
|
||||
].map((x) => (
|
||||
<span key={x.t} className="flex items-center gap-1 text-[10px] text-[#9DA0B0]">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: x.c }} /> {x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<svg ref={svgRef} viewBox="0 0 800 500" className="w-full h-[500px] bg-[#0a0e1a]">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#5C6080" />
|
||||
</marker>
|
||||
</defs>
|
||||
{graphData.edges.map((e: any, i: number) => {
|
||||
const sn = layoutNodes.find((n: any) => n.id === e.source)
|
||||
const tn = layoutNodes.find((n: any) => n.id === e.target)
|
||||
if (!sn || !tn) return null
|
||||
return (
|
||||
<g key={`e-${i}`}>
|
||||
<line
|
||||
x1={sn.x}
|
||||
y1={sn.y}
|
||||
x2={tn.x}
|
||||
y2={tn.y}
|
||||
stroke={e.direction === 'in' ? '#22D3EE' : '#FF3366'}
|
||||
strokeWidth={1.5}
|
||||
opacity={0.4}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<text
|
||||
x={(sn.x + tn.x) / 2}
|
||||
y={(sn.y + tn.y) / 2 - 5}
|
||||
textAnchor="middle"
|
||||
className="text-[8px]"
|
||||
fill="#5C6080"
|
||||
>
|
||||
{e.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{layoutNodes.map((n: any) => (
|
||||
<g key={n.id} onClick={() => setSelectedNode(n)} className="cursor-pointer">
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.type === 'target' ? 18 : 12}
|
||||
fill={nodeColor(n.type)}
|
||||
fillOpacity={0.15}
|
||||
stroke={nodeColor(n.type)}
|
||||
strokeWidth={n.type === 'target' ? 2.5 : 1.5}
|
||||
/>
|
||||
<text
|
||||
x={n.x}
|
||||
y={n.y + 4}
|
||||
textAnchor="middle"
|
||||
className="text-[8px] font-mono"
|
||||
fill={nodeColor(n.type)}
|
||||
>
|
||||
{n.label?.slice(0, 10)}
|
||||
</text>
|
||||
{n.type === 'target' && (
|
||||
<text x={n.x} y={n.y - 24} textAnchor="middle" className="text-[10px] font-bold" fill="#FF3366">
|
||||
TARGET
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: nodeColor(selectedNode.type) }} />
|
||||
<span className="text-[13px] font-bold text-[#F1F1F6]">{selectedNode.label}</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${nodeColor(selectedNode.type)}20`, color: nodeColor(selectedNode.type) }}
|
||||
>
|
||||
{selectedNode.type}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setSelectedNode(null)} className="text-[#5C6080] hover:text-[#F1F1F6]">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] text-[#5C6080] space-y-1">
|
||||
<p>
|
||||
Address: <code className="text-purple-300 bg-purple-500/5 px-1 rounded">{selectedNode.id}</code>
|
||||
</p>
|
||||
<p>Chain: {selectedNode.chain}</p>
|
||||
{selectedNode.hop && <p>Hop: {selectedNode.hop}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddress(selectedNode.id)
|
||||
setChain(selectedNode.chain || chain)
|
||||
}}
|
||||
className="btn btn-glass btn-sm mt-3 flex items-center gap-2"
|
||||
>
|
||||
<Target className="w-3.5 h-3.5" /> Trace from this wallet
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!graphData && !loading && (
|
||||
<div className="glass-card rounded-xl p-12 text-center">
|
||||
<Network className="w-12 h-12 mx-auto mb-3 text-[#3A3E58]" />
|
||||
<h3 className="text-[16px] font-bold text-[#5C6080] mb-2">Enter a wallet address to trace connections</h3>
|
||||
<p className="text-[12px] text-[#5C6080] max-w-md mx-auto">
|
||||
Visualize fund flows, identify suspicious patterns, and draw lines between wallets. See where the money
|
||||
goes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Pro Feature: Generate Dossier Modal ──────────────────────── */
|
||||
|
||||
export function GenerateDossierModal({ report, onClose }: { report: any; onClose: () => void }) {
|
||||
const [investigatorName, setInvestigatorName] = useState('Marcus Aurelius')
|
||||
const [narrative, setNarrative] = useState(
|
||||
"I built CRM as a legitimate community-driven project. On March 2026, I noticed anomalous selling pressure that didn't match organic market behavior. What followed was a painstaking on-chain investigation that revealed a coordinated, military-grade extraction operation by the SOSANA/SHIFT syndicate.",
|
||||
)
|
||||
const [qwenApiKey, setQwenApiKey] = useState(
|
||||
'sk-ws-H.IDMLDY.5Sfa.MEQCIHpSrPVrHMi5lSRbSFHP4g-7soIIiit0nCmbyf1uAWYuAiB-Cdiooem78yNSYH8rhPhUDsV2fxULbpZ6wjGFbNsR5Q',
|
||||
)
|
||||
const [publishStatus, setPublishStatus] = useState<'private' | 'public' | 'law_enforcement'>('law_enforcement')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [dossierHtml, setDossierHtml] = useState('')
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
// 1. Publish the report with the chosen status
|
||||
await CommunityAPI.publishReport({
|
||||
report_id: report.id,
|
||||
publish_status: publishStatus,
|
||||
investigator_narrative: narrative,
|
||||
include_full_wallet_history: true,
|
||||
redact_personal_info: false,
|
||||
})
|
||||
|
||||
// 2. Generate the dossier
|
||||
const result = await CommunityAPI.generateDossier({
|
||||
report_id: report.id,
|
||||
investigator_name: investigatorName,
|
||||
narrative: narrative,
|
||||
qwen_api_key: qwenApiKey,
|
||||
})
|
||||
|
||||
setDossierHtml(result.dossier_html)
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to generate dossier')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([dossierHtml], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `CRM_Forensic_Dossier_${report.id}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (dossierHtml) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass rounded-2xl p-6 border-purple-500/20 w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-black text-white flex items-center gap-2">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" /> Dossier Generated
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-xl">
|
||||
<p className="text-sm text-green-300">
|
||||
The forensic dossier has been successfully generated with AI-powered graphics and your investigator
|
||||
narrative.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 rounded-xl text-white font-bold transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Download HTML Dossier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Safe preview: create a blob URL and open it, avoiding document.write XSS risks
|
||||
const blob = new Blob([dossierHtml], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const newWindow = window.open(url, '_blank')
|
||||
if (newWindow) {
|
||||
// Revoke after a short delay to ensure the new tab has loaded it
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-white/5 border border-white/10 hover:bg-white/10 rounded-xl text-white font-bold transition-all"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" /> Preview in New Tab
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center mt-4">
|
||||
Tip: Open the downloaded HTML in Chrome/Edge and press Ctrl+P (Cmd+P) to save as a beautiful PDF. Ensure
|
||||
"Background graphics" is checked in print settings.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass rounded-2xl p-6 border-purple-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-black text-white flex items-center gap-2">
|
||||
<Crown className="w-5 h-5 text-yellow-400" /> Pro: Generate Forensic Dossier
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Publishing Status
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['private', 'public', 'law_enforcement'] as const).map((status) => (
|
||||
<button
|
||||
type="button"
|
||||
key={status}
|
||||
onClick={() => setPublishStatus(status)}
|
||||
className={`px-3 py-2 rounded-xl text-xs font-bold uppercase transition-all ${publishStatus === status ? 'bg-purple-600 text-white' : 'bg-white/5 text-gray-400 hover:bg-white/10'}`}
|
||||
>
|
||||
{status === 'law_enforcement' ? 'Law Enforcement' : status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Lead Investigator Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={investigatorName}
|
||||
onChange={(e) => setInvestigatorName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Investigator's Narrative
|
||||
</label>
|
||||
<textarea
|
||||
value={narrative}
|
||||
onChange={(e) => setNarrative(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1.5 block">
|
||||
Qwen AI API Key (for Graphics)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={qwenApiKey}
|
||||
onChange={(e) => setQwenApiKey(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-600 focus:outline-none focus:border-purple-500/50 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
Used to generate professional network topology graphics for the report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white font-bold transition-all mt-4"
|
||||
>
|
||||
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileText className="w-4 h-4" />}
|
||||
{generating ? 'Generating AI Dossier...' : 'Generate & Download Dossier'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
45
src/components/market-intel/AlphaCard.tsx
Normal file
45
src/components/market-intel/AlphaCard.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export function AlphaCard({ signal, index }: { signal: AlphaSignal; index: number }) {
|
||||
const urgencyColor =
|
||||
signal.urgency === 'immediate'
|
||||
? 'text-red-400 bg-red-500/20'
|
||||
: signal.urgency === 'soon'
|
||||
? 'text-orange-400 bg-orange-500/20'
|
||||
: 'text-cyan-400 bg-cyan-500/20'
|
||||
const TypeIcon =
|
||||
signal.type === 'dev_movement'
|
||||
? Crosshair
|
||||
: signal.type === 'insider_buy'
|
||||
? Eye
|
||||
: signal.type === 'bundler_forming'
|
||||
? Zap
|
||||
: signal.type === 'smart_money_in'
|
||||
? TrendingUp
|
||||
: signal.type === 'narrative_shift'
|
||||
? Flame
|
||||
: Wallet
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white text-sm font-medium">{signal.token_symbol}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${urgencyColor}`}>{signal.urgency}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(signal.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{signal.description}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>Confidence {signal.confidence}%</span>
|
||||
{signal.wallet_label && <span>Via {signal.wallet_label}</span>}
|
||||
{signal.amount && <span>{fmtUsd(signal.amount)}</span>}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rug Card ─────────────────────────────────────────────────────
|
||||
31
src/components/market-intel/BundlerCard.tsx
Normal file
31
src/components/market-intel/BundlerCard.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export function BundlerCard({ b }: { b: BundlerActivity }) {
|
||||
const statusColor =
|
||||
b.status === 'forming' ? 'text-yellow-400' : b.status === 'active' ? 'text-green-400' : 'text-red-400'
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">{b.symbol}</span>
|
||||
<span className={`text-xs font-medium ${statusColor}`}>{b.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm mb-2">
|
||||
<div>
|
||||
<div className="text-white font-medium">{b.wallet_count}</div>
|
||||
<div className="text-gray-500 text-xs">Wallets</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{b.coordinated_buy_count}</div>
|
||||
<div className="text-gray-500 text-xs">Buys</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{fmtUsd(b.total_volume)}</div>
|
||||
<div className="text-gray-500 text-xs">Volume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Pattern: <span className="text-purple-400">{b.pattern}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DEX Flow Card ────────────────────────────────────────────────
|
||||
38
src/components/market-intel/ChainCard.tsx
Normal file
38
src/components/market-intel/ChainCard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export function ChainCard({ chain }: { chain: ChainActivity }) {
|
||||
const statusColor =
|
||||
chain.status === 'hot' ? 'text-red-400' : chain.status === 'warm' ? 'text-orange-400' : 'text-cyan-400'
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">{chain.chain}</span>
|
||||
<span className={`text-xs font-medium ${statusColor}`}>{chain.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">TPS</span> <span className="text-white ml-1">{chain.tps}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Gas</span> <span className="text-white ml-1">{chain.gas_usd}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Txs 24h</span> <span className="text-white ml-1">{chain.txs_24h}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Active</span>{' '}
|
||||
<span className="text-white ml-1">{chain.active_addresses?.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-green-500 to-red-500"
|
||||
style={{ width: `${chain.ai_risk_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Risk {chain.ai_risk_score}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Alpha Card ───────────────────────────────────────────────────
|
||||
26
src/components/market-intel/DexFlowCard.tsx
Normal file
26
src/components/market-intel/DexFlowCard.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export function DexFlowCard({ flow }: { flow: DexFlow }) {
|
||||
const netPositive = flow.net_flow_1h > 0
|
||||
const buyPct = Math.min((flow.buy_pressure_1h / (flow.buy_pressure_1h + flow.sell_pressure_1h)) * 100, 100) || 50
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">{flow.symbol}</span>
|
||||
<span className={`text-xs ${netPositive ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{netPositive ? '+' : ''}
|
||||
{fmtUsd(flow.net_flow_1h)} 1h
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-green-500" style={{ width: `${buyPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{Math.round(buyPct)}% buy</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
LP +{fmtUsd(flow.lp_additions_24h)} / -{fmtUsd(flow.lp_removals_24h)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prediction Market Card ───────────────────────────────────────
|
||||
28
src/components/market-intel/FlowCard.tsx
Normal file
28
src/components/market-intel/FlowCard.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export function FlowCard({ flow }: { flow: SmartMoneyFlow }) {
|
||||
const isInflow = flow.net_flow > 0
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">{flow.sector}</span>
|
||||
<span className={`text-sm font-medium ${isInflow ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isInflow ? '+' : ''}
|
||||
{fmtUsd(flow.net_flow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 mb-2">
|
||||
{flow.top_tokens?.slice(0, 3).map((t, _i) => (
|
||||
<div key={t.symbol} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">{t.symbol}</span>
|
||||
<span className="text-white">{fmtUsd(t.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="text-green-400">In {fmtUsd(flow.inflow_24h)}</span>
|
||||
<span className="text-red-400">Out {fmtUsd(flow.outflow_24h)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bundler Card ─────────────────────────────────────────────────
|
||||
62
src/components/market-intel/LaunchCard.tsx
Normal file
62
src/components/market-intel/LaunchCard.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export function LaunchCard({
|
||||
token,
|
||||
index,
|
||||
onClick,
|
||||
}: {
|
||||
token: LaunchToken
|
||||
index: number
|
||||
onClick: (t: LaunchToken) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
onClick={() => onClick(token)}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] hover:border-white/[0.12] cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-600/30 to-cyan-900/30 flex items-center justify-center">
|
||||
<Rocket className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{token.name}</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{token.symbol} · {token.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-cyan-500/20 text-cyan-300">{token.age_minutes}m old</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-2">
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.price)}</div>
|
||||
<div className="text-gray-500 text-xs">Price</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.market_cap)}</div>
|
||||
<div className="text-gray-500 text-xs">MCap</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{token.holders}</div>
|
||||
<div className="text-gray-500 text-xs">Holders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: riskColor(token.risk_score) }} />
|
||||
<span className="text-xs text-gray-400">Risk {token.risk_score}</span>
|
||||
</div>
|
||||
{token.bundler_detected && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/20 text-orange-300 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Bundler
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scam Card ────────────────────────────────────────────────────
|
||||
25
src/components/market-intel/PredictionCard.tsx
Normal file
25
src/components/market-intel/PredictionCard.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export function PredictionCard({ market, index }: { market: PredictionMarket; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-white text-sm font-medium line-clamp-1">{market.title}</span>
|
||||
</div>
|
||||
{market.trending && <span className="text-xs px-2 py-0.5 rounded bg-blue-500/20 text-blue-300">Trending</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm mb-2">
|
||||
<div>
|
||||
<div className="text-green-400 font-medium">{(market.yes_price * 100).toFixed(0)}%</div>
|
||||
<div className="text-gray-500 text-xs">Yes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-red-400 font-medium">{(market.no_price * 100).toFixed(0)}%</div>
|
||||
<div className="text-gray-500 text-xs">No</div>
|
||||
</div>
|
||||
<div>
|
||||
29
src/components/market-intel/RugCard.tsx
Normal file
29
src/components/market-intel/RugCard.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export function RugCard({ rug, index }: { rug: LikelyRug; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-red-500/20 bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skull className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white text-sm font-medium">{rug.symbol}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-500/20 text-red-400">{rug.confidence}% conf</span>
|
||||
</div>
|
||||
<span className="text-red-400 text-xs font-medium">{rug.time_to_impact}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{rug.reasons?.[0]}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rug.indicators?.slice(0, 3).map((ind, _i) => (
|
||||
<span key={ind} className="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-300">
|
||||
{ind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Runner Card ──────────────────────────────────────────────────
|
||||
36
src/components/market-intel/RunnerCard.tsx
Normal file
36
src/components/market-intel/RunnerCard.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export function RunnerCard({ runner, index }: { runner: PotentialRunner; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-green-500/20 bg-green-500/[0.03] hover:bg-green-500/[0.06] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="w-4 h-4 text-green-400" />
|
||||
<span className="text-white text-sm font-medium">{runner.symbol}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">AI {runner.ai_prediction}%</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(runner.detected_at)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{runner.narrative}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-gray-500">
|
||||
Smart Money <span className="text-green-400">{fmtUsd(runner.smart_money_inflows)}</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Holders <span className="text-green-400">+{runner.holder_growth_rate}%</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Volume <span className="text-green-400">+{runner.volume_surge}x</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Social <span className="text-green-400">+{runner.social_mentions_delta}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Flow Card ────────────────────────────────────────────────────
|
||||
44
src/components/market-intel/ScamCard.tsx
Normal file
44
src/components/market-intel/ScamCard.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export function ScamCard({ alert, index }: { alert: ScamAlert; index: number }) {
|
||||
const severityColor =
|
||||
alert.severity === 'critical'
|
||||
? 'text-red-400 bg-red-500/20'
|
||||
: alert.severity === 'high'
|
||||
? 'text-orange-400 bg-orange-500/20'
|
||||
: 'text-yellow-400 bg-yellow-500/20'
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skull className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white text-sm font-medium">{alert.symbol}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${severityColor}`}>{alert.severity}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(alert.detected_at)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{alert.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{alert.indicators?.slice(0, 3).map((ind, _i) => (
|
||||
<span key={ind} className="text-xs px-2 py-0.5 rounded bg-white/[0.06] text-gray-400">
|
||||
{ind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>{alert.victims} victims</span>
|
||||
<span>{fmtUsd(alert.amount_stolen)} stolen</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded ${alert.status === 'active' ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}`}
|
||||
>
|
||||
{alert.status}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Whale Card ───────────────────────────────────────────────────
|
||||
54
src/components/market-intel/TokenRow.tsx
Normal file
54
src/components/market-intel/TokenRow.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export function TokenRow({
|
||||
token,
|
||||
index,
|
||||
onClick,
|
||||
showAiScore,
|
||||
}: {
|
||||
token: MoverToken
|
||||
index: number
|
||||
onClick: (t: MoverToken) => void
|
||||
showAiScore?: boolean
|
||||
}) {
|
||||
const isUp = token.change_24h >= 0
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.02, 0.3) }}
|
||||
onClick={() => onClick(token)}
|
||||
className="group grid grid-cols-12 gap-2 items-center px-4 py-3 rounded-xl border border-transparent hover:bg-white/[0.04] hover:border-white/[0.08] cursor-pointer transition-all"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3 min-w-0">
|
||||
<span className="text-xs text-gray-600 w-5 text-right shrink-0">#{token.rank}</span>
|
||||
<TokenIcon address={token.address} symbol={token.symbol} size={32} chain={token.chain} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm font-medium truncate">{token.name}</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{token.symbol} · {token.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.price)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className={`text-sm font-medium ${isUp ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isUp ? <ArrowUpRight className="w-3 h-3 inline mr-1" /> : <ArrowDownRight className="w-3 h-3 inline mr-1" />}
|
||||
{fmtPct(token.change_24h)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className="text-gray-300 text-sm">{fmtUsd(token.volume_24h)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right flex items-center justify-end gap-2">
|
||||
{showAiScore && token.ai_score != null && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-purple-500/20 text-purple-300">AI {token.ai_score}</span>
|
||||
)}
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: riskColor(token.risk_score) }} />
|
||||
<span className="text-xs text-gray-400">{token.risk_score}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Launch Card ─────────────────────────────────────────────────
|
||||
36
src/components/market-intel/WhaleCard.tsx
Normal file
36
src/components/market-intel/WhaleCard.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export function WhaleCard({ move, index }: { move: WhaleMovement; index: number }) {
|
||||
const isBuy = move.type === 'buy'
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center ${isBuy ? 'bg-green-500/20' : 'bg-red-500/20'}`}
|
||||
>
|
||||
<Wallet className={`w-4 h-4 ${isBuy ? 'text-green-400' : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">
|
||||
{move.wallet_label || `${move.from_wallet?.slice(0, 8)}...`}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{move.token_symbol} · {move.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${isBuy ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isBuy ? '+' : '-'}
|
||||
{fmtUsd(move.value_usd)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">{fmtTimeAgo(move.timestamp)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chain Card ───────────────────────────────────────────────────
|
||||
131
src/components/mcp-docs/AuthenticationHierarchySection.tsx
Normal file
131
src/components/mcp-docs/AuthenticationHierarchySection.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { Shield, Lock, Key } from 'lucide-react'
|
||||
|
||||
export function AuthenticationHierarchySection() {
|
||||
const tiers = [
|
||||
{
|
||||
level: 1,
|
||||
title: 'Wallet Connected',
|
||||
icon: Wallet,
|
||||
color: 'text-cyan-400',
|
||||
bg: 'bg-cyan-500/10',
|
||||
border: 'border-cyan-500/30',
|
||||
bar: 'w-full',
|
||||
benefits: [
|
||||
'3 free queries per tool',
|
||||
'30 req/min rate limit',
|
||||
'Full API access',
|
||||
'Refund eligible',
|
||||
'Priority queue',
|
||||
],
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
title: 'Device Fingerprint',
|
||||
icon: Fingerprint,
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-500/10',
|
||||
border: 'border-green-500/30',
|
||||
bar: 'w-3/4',
|
||||
benefits: ['1 free query per tool', '10 req/min rate limit', 'Full API access', 'Refund eligible'],
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
title: 'Turnstile Verified',
|
||||
icon: Shield,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500/30',
|
||||
bar: 'w-1/2',
|
||||
benefits: ['0 free queries (no trial)', '5 req/min rate limit', 'Paid-only access'],
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
title: 'Unknown / No Auth',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-red-400',
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500/30',
|
||||
bar: 'w-1/4',
|
||||
benefits: ['0 free queries', '2 req/min rate limit', 'Subject to challenges'],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Authentication Hierarchy</h2>
|
||||
<p className="text-gray-400">
|
||||
Rug Munch uses a tiered authentication system. Higher trust = more free queries + higher rate limits.
|
||||
</p>
|
||||
|
||||
{/* Hierarchy visualization */}
|
||||
<div className="space-y-3">
|
||||
{tiers.map((tier) => {
|
||||
const Icon = tier.icon
|
||||
return (
|
||||
<div key={tier.level} className={`p-4 rounded-xl bg-[#0e0e16] border ${tier.border}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${tier.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-4 h-4 ${tier.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono ${tier.color}`}>TIER {tier.level}</span>
|
||||
<h3 className="text-sm font-semibold text-white">{tier.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32 h-2 rounded-full bg-black/40 overflow-hidden">
|
||||
<div className={`h-full ${tier.bg} ${tier.bar}`} style={{ borderRadius: '4px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tier.benefits.map((b) => (
|
||||
<span key={b} className="text-[10px] px-2 py-1 rounded-md bg-black/20 text-gray-400">
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* How auth is determined */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">How Authentication Tier is Determined</h3>
|
||||
<div className="space-y-2 text-sm text-gray-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-cyan-400 font-mono text-xs mt-0.5 w-20 shrink-0">x-pay header</span>
|
||||
<span>Present = wallet verification. Absent = check for device fingerprint.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-green-400 font-mono text-xs mt-0.5 w-20 shrink-0">X-Device-Id</span>
|
||||
<span>Valid fingerprint hash = Tier 2. Missing or invalid = fall through.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-400 font-mono text-xs mt-0.5 w-20 shrink-0">cf-turnstile</span>
|
||||
<span>Cloudflare Turnstile token present = Tier 3. Otherwise = Tier 4.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-400 font-mono text-xs mt-0.5 w-20 shrink-0">No headers</span>
|
||||
<span>Anonymous/unknown = Tier 4 (heavily rate-limited, may be challenged).</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade path */}
|
||||
<div className="p-4 rounded-xl bg-gradient-to-r from-green-500/5 to-cyan-500/5 border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ArrowRight className="w-4 h-4 text-green-400" />
|
||||
<h3 className="text-sm font-semibold text-green-300">Upgrade Your Tier</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Connect a wallet to jump from Tier 2 to Tier 1 instantly. Get 3x more free queries and 3x higher rate limits.
|
||||
No KYC, no signup — just sign a message to prove wallet ownership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
27
src/components/mcp-docs/CodeBlock.tsx
Normal file
27
src/components/mcp-docs/CodeBlock.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Check, Copy } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default export function CodeBlock({ code, lang, _id }: { code: string; lang: string; id: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<div className="relative rounded-lg bg-black/40 border border-white/5 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-white/5">
|
||||
<span className="text-xs text-gray-500 font-mono">{lang}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 text-xs text-gray-300 overflow-x-auto whitespace-pre font-mono leading-relaxed">{code}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/components/mcp-docs/DeviceFingerprintSection.tsx
Normal file
120
src/components/mcp-docs/DeviceFingerprintSection.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { Fingerprint, Shield, CheckCircle, Code } from 'lucide-react'
|
||||
|
||||
export function DeviceFingerprintSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Device Fingerprint & Anti-Abuse</h2>
|
||||
<p className="text-gray-400">
|
||||
Every request must include a device fingerprint. This prevents trial abuse and enforces rate limits without
|
||||
requiring accounts.
|
||||
</p>
|
||||
|
||||
{/* The X-Device-Id header */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-cyan-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Fingerprint className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-cyan-300">X-Device-Id Header</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
A SHA-256 hash derived from browser Canvas, WebGL renderer, and installed fonts. Sent with every request.
|
||||
</p>
|
||||
<CodeBlock
|
||||
lang="javascript"
|
||||
id="device-fingerprint"
|
||||
code={`// Generate device fingerprint in the browser
|
||||
async function getDeviceFingerprint() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl');
|
||||
|
||||
// Canvas fingerprint
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText('RugMunch', 2, 2);
|
||||
const canvasData = canvas.toDataURL();
|
||||
|
||||
// WebGL renderer
|
||||
const renderer = gl.getParameter(gl.RENDERER);
|
||||
|
||||
// Combine sources and hash
|
||||
const raw = \`\${canvasData}|\${renderer}|\${navigator.fonts}\`;
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Use in requests:
|
||||
const deviceId = await getDeviceFingerprint();
|
||||
fetch('/api/v1/x402-tools/audit', {
|
||||
headers: { 'X-Device-Id': deviceId }
|
||||
});`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anti-abuse tiers */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Anti-Abuse Layers</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{
|
||||
icon: Fingerprint,
|
||||
title: 'Device Fingerprint',
|
||||
color: 'text-green-400',
|
||||
desc: 'Canvas + WebGL + font hash. Identifies unique browsers. Prevents incognito/spam abuse.',
|
||||
detail: 'X-Device-Id header. SHA-256 of browser fingerprint data.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Turnstile Challenge',
|
||||
color: 'text-amber-400',
|
||||
desc: 'Cloudflare Turnstile invisible challenge for suspicious requests. Blocks bots.',
|
||||
detail: 'Triggered automatically for anomalous fingerprint patterns.',
|
||||
},
|
||||
{
|
||||
icon: Wallet,
|
||||
title: 'Wallet Binding',
|
||||
color: 'text-cyan-400',
|
||||
desc: 'Connected wallet address bound to trial counter. Higher trust = more free queries.',
|
||||
detail: '3 free queries per tool (vs 1 for fingerprint-only).',
|
||||
},
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
title: 'Rate Limiting',
|
||||
color: 'text-red-400',
|
||||
desc: 'Per-device rate limits. Exponential backoff for repeated 402s without payment.',
|
||||
detail: 'Max 10 requests/minute (fingerprint), 30 requests/minute (wallet).',
|
||||
},
|
||||
].map((layer) => {
|
||||
const Icon = layer.icon
|
||||
return (
|
||||
<div key={layer.title} className="p-3 rounded-lg bg-black/20 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className={`w-4 h-4 ${layer.color}`} />
|
||||
<h4 className="text-sm font-medium text-white">{layer.title}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{layer.desc}</p>
|
||||
<p className="text-[10px] text-gray-600 mt-1 font-mono">{layer.detail}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important note */}
|
||||
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-300">Fingerprint Spoofing</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Attempting to bypass fingerprint tracking by rotating device IDs is detected via behavioral analysis and
|
||||
may result in permanent rate limiting. Connect a wallet for higher limits instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
205
src/components/mcp-docs/DiscoveryEndpointsSection.tsx
Normal file
205
src/components/mcp-docs/DiscoveryEndpointsSection.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { Radio, Globe, ExternalLink } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function DiscoveryEndpointsSection() {
|
||||
const endpoints = [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/.well-known/x402',
|
||||
label: 'x402 Discovery',
|
||||
desc: 'Returns the full x402 payment configuration—including all 103 tools, pricing, supported chains, and facilitator endpoints.',
|
||||
responseType: `{ "tools": 201, "chains": 13, "facilitator": "...", "tools_endpoint": "..." }`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/v1/x402/tools-catalog',
|
||||
label: 'Tools Catalog',
|
||||
desc: 'Returns 125+ unique tools across 13 chains with full metadata—name, description, price, category, trial count, chains, service info.',
|
||||
responseType: `{ "tools": [...], "total_tools": 97, "total_chains": 7, "chains": {...} }`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: `${WORKER_BASE}/mcp`,
|
||||
label: 'Base MCP Endpoint',
|
||||
desc: 'MCP protocol endpoint on the Base worker. Returns MCP server capabilities and tool definitions for EVM-chain tools.',
|
||||
responseType: `{ "protocol": "mcp", "version": "1.0", "tools": [...] }`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: `${WORKER_SOL}/mcp`,
|
||||
label: 'Solana MCP Endpoint',
|
||||
desc: 'MCP protocol endpoint on the Solana worker. Returns MCP server capabilities and tool definitions for Solana tools.',
|
||||
responseType: `{ "protocol": "mcp", "version": "1.0", "tools": [...] }`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/v1/x402/refund',
|
||||
label: 'Refund Request',
|
||||
desc: 'Request a refund for a tool execution that returned no data. Requires original transaction hash and tool ID.',
|
||||
responseType: `{ "status": "refunded", "amount": "0.05", "refund_tx": "..." }`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/v1/x402/verify',
|
||||
label: 'Payment Verify',
|
||||
desc: 'Verify an x402 payment was received and confirmed. Used by self-verification chains to prove on-chain settlement.',
|
||||
responseType: `{ "verified": true, "chain": "ethereum", "tx_hash": "..." }`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/v1/bulletin/latest',
|
||||
label: 'Bulletin Board Posts',
|
||||
desc: 'The first bot-human message board in crypto. Bots and humans post alerts, analysis, and discussion. Bots register via x402 $1 fee.',
|
||||
responseType: `{ "posts": [...], "total": 50 }`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/v1/bulletin/bots/register',
|
||||
label: 'Bot Registration',
|
||||
desc: 'Register an AI bot to join the bulletin board. $1 x402 fee required. Bots get verified badges and can post across categories.',
|
||||
responseType: `{ "bot_profile": {...}, "fee_required": 1.0, "status": "active" }`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/v1/bulletin/posts',
|
||||
label: 'Create Post',
|
||||
desc: 'Create a new post on the bulletin board. Supports alerts, analysis, discussion, rehab stories, debates, and more.',
|
||||
responseType: `{ "id": "...", "title": "...", "category": "alert" }`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Bot Discovery Endpoints</h2>
|
||||
<p className="text-gray-400">
|
||||
Machine-readable endpoints for bots, agents, and CI/CD pipelines to discover available tools, pricing, and
|
||||
payment configuration.
|
||||
</p>
|
||||
|
||||
{/* Why discovery matters */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold text-purple-300">Why Discovery?</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
AI agents and automated systems need to discover available tools without manual configuration. The
|
||||
/.well-known/x402 endpoint follows the x402 protocol standard for programmatic tool discovery.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Endpoint list */}
|
||||
<div className="space-y-3">
|
||||
{endpoints.map((ep) => (
|
||||
<div key={ep.url} className="p-4 rounded-xl bg-[#0e0e16] border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded font-mono font-bold ${
|
||||
ep.method === 'GET' ? 'bg-green-500/10 text-green-400' : 'bg-blue-500/10 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-white">{ep.label}</h3>
|
||||
</div>
|
||||
<code className="text-xs text-cyan-400 font-mono break-all block mb-2">{ep.url}</code>
|
||||
<p className="text-xs text-gray-400 mb-3">{ep.desc}</p>
|
||||
<div className="p-2 rounded-lg bg-black/20">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mb-1">Response Shape</p>
|
||||
<pre className="text-[10px] text-gray-500 font-mono">{ep.responseType}</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Discovery code examples */}
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">Discover Tools (Python Bot)</h4>
|
||||
<CodeBlock
|
||||
lang="python"
|
||||
id="discover-py"
|
||||
code={`import requests
|
||||
|
||||
# Discover all available tools via x402 standard
|
||||
discovery = requests.get('https://rugmunch.io/.well-known/x402').json()
|
||||
print(f"Found {discovery['tools']} tools on {discovery['chains']} chains")
|
||||
|
||||
# Get full tool catalog with metadata
|
||||
catalog = requests.get('https://rugmunch.io/api/v1/x402/tools-catalog').json()
|
||||
for tool in catalog['tools']:
|
||||
print(f" {tool['id']}: {tool['price']} ({tool['category']})")`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">Discover Tools (cURL)</h4>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="discover-curl"
|
||||
code={`# x402 standard discovery endpoint
|
||||
curl https://rugmunch.io/.well-known/x402 | jq
|
||||
|
||||
# Full tools catalog
|
||||
curl https://rugmunch.io/api/v1/x402/tools-catalog | jq '.total_tools, .chains'
|
||||
|
||||
# MCP discovery (primary endpoint)
|
||||
curl https://mcp.rugmunch.io/mcp | jq '.tools | length'
|
||||
|
||||
# MCP discovery on Solana worker (fallback)
|
||||
curl https://x402-sol.cryptorugmuncher.workers.dev/mcp | jq '.tools | length'`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">Discover Tools (JavaScript / AI Agent)</h4>
|
||||
<CodeBlock
|
||||
lang="javascript"
|
||||
id="discover-js"
|
||||
code={`// Discover tools for an AI agent
|
||||
const discovery = await fetch('https://rugmunch.io/.well-known/x402');
|
||||
const config = await discovery.json();
|
||||
console.log(\`\${config.tools} tools, \${config.chains} chains\`);
|
||||
|
||||
// Get detailed catalog
|
||||
const catalog = await fetch('https://rugmunch.io/api/v1/x402/tools-catalog');
|
||||
const { tools, total_tools, total_chains } = await catalog.json();
|
||||
|
||||
// Filter security tools under $0.05
|
||||
const cheapSecurity = tools.filter(t =>
|
||||
t.category === 'security' && t.priceUsd <= 0.05
|
||||
);
|
||||
console.log(\`Found \${cheapSecurity.length} security tools under $0.05\`);`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2 Workers architecture */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Cloudflare Worker Architecture</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Tools are served through 2 Cloudflare Workers for low-latency, edge-first delivery:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-semibold text-blue-300">Base Worker</h4>
|
||||
</div>
|
||||
<code className="text-[10px] text-cyan-400 font-mono">{WORKER_BASE}</code>
|
||||
<p className="text-[10px] text-gray-500 mt-1">EVM-chain tools (Base, ETH, BSC, ARB, OPT, POL)</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/5 border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4 text-purple-400" />
|
||||
<h4 className="text-xs font-semibold text-purple-300">Solana Worker</h4>
|
||||
</div>
|
||||
<code className="text-[10px] text-cyan-400 font-mono">{WORKER_SOL}</code>
|
||||
<p className="text-[10px] text-gray-500 mt-1">Solana-specific tools with SPL-USDC support</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
102
src/components/mcp-docs/DocLinksSection.tsx
Normal file
102
src/components/mcp-docs/DocLinksSection.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { BookOpen, ExternalLink, Globe } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function DocLinksSection() {
|
||||
const links = [
|
||||
{
|
||||
title: 'README',
|
||||
desc: 'Project overview, setup, and architecture',
|
||||
href: 'https://github.com/cryptorugmuncher/rug-munch-intelligence#readme',
|
||||
icon: BookOpen,
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
{
|
||||
title: 'User Guide',
|
||||
desc: 'Step-by-step guide for getting started',
|
||||
href: 'https://docs.rugmunch.io/user-guide',
|
||||
icon: Book,
|
||||
color: 'text-cyan-400',
|
||||
},
|
||||
{
|
||||
title: 'API Reference',
|
||||
desc: 'Full REST API documentation with examples',
|
||||
href: 'https://docs.rugmunch.io/api',
|
||||
icon: Code,
|
||||
color: 'text-green-400',
|
||||
},
|
||||
{
|
||||
title: 'GitHub Repository',
|
||||
desc: 'Source code, issues, and contributions',
|
||||
href: 'https://github.com/cryptorugmuncher/rug-munch-intelligence',
|
||||
icon: Globe,
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
title: 'x402 Protocol Spec',
|
||||
desc: 'HTTP 402 micropayment protocol specification',
|
||||
href: 'https://x402.org',
|
||||
icon: CreditCard,
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
title: 'MCP Specification',
|
||||
desc: 'Model Context Protocol standard documentation',
|
||||
href: 'https://modelcontextprotocol.io',
|
||||
icon: Bot,
|
||||
color: 'text-pink-400',
|
||||
},
|
||||
{
|
||||
title: 'Smithery Registry',
|
||||
desc: 'Discover RMI on the Smithery MCP registry',
|
||||
href: 'https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence',
|
||||
icon: BadgeCheck,
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
title: 'Glama Registry',
|
||||
desc: 'Discover RMI on the Glama MCP registry',
|
||||
href: 'https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence',
|
||||
icon: BadgeCheck,
|
||||
color: 'text-indigo-400',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Documentation & Links</h2>
|
||||
<p className="text-gray-400">Official docs, repositories, and registries for Rug Munch Intelligence.</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{links.map((link, i) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<motion.a
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="flex items-start gap-3 p-4 rounded-xl bg-[#0e0e16] border border-white/5 hover:border-purple-500/30 transition-all group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-white/[0.03] flex items-center justify-center shrink-0">
|
||||
<Icon className={`w-4 h-4 ${link.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-white group-hover:text-purple-300 transition-colors">
|
||||
{link.title}
|
||||
</p>
|
||||
<ExternalLink className="w-3 h-3 text-gray-600 group-hover:text-purple-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{link.desc}</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/components/mcp-docs/FAQSection.tsx
Normal file
29
src/components/mcp-docs/FAQSection.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
export function FAQSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Frequently Asked Questions</h2>
|
||||
<p className="text-gray-400">Common questions about Rug Munch Intelligence, MCP, payments, wallets, and more.</p>
|
||||
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{FAQ_ITEMS.map((item, i) => (
|
||||
<AccordionItem
|
||||
key={i}
|
||||
value={`faq-${i}`}
|
||||
className="rounded-xl bg-[#0e0e16] border border-white/5 px-4 data-[state=open]:border-purple-500/20 transition-colors"
|
||||
>
|
||||
<AccordionTrigger className="text-sm font-medium text-white hover:text-purple-300 hover:no-underline py-4">
|
||||
<div className="flex items-center gap-3 text-left">
|
||||
<HelpCircle className="w-4 h-4 text-purple-400 shrink-0" />
|
||||
<span>{item.q}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-sm text-gray-400 leading-relaxed pb-4">{item.a}</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
223
src/components/mcp-docs/MCPIntegrationSection.tsx
Normal file
223
src/components/mcp-docs/MCPIntegrationSection.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { Bot, Code, Terminal } from 'lucide-react'
|
||||
|
||||
export function MCPIntegrationSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">MCP Integration</h2>
|
||||
<p className="text-gray-400">
|
||||
Connect AI agents (Claude, ChatGPT, Cursor, etc.) to all 103 tools via Model Context Protocol. Each Cloudflare
|
||||
Worker exposes its own /mcp endpoint.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* MCP endpoints */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">MCP Server Endpoints</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2.5 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-blue-300 font-semibold">Base / EVM</span>
|
||||
</div>
|
||||
<code className="text-xs text-cyan-400 font-mono">{WORKER_BASE}/mcp</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2.5 rounded-lg bg-purple-500/5 border border-purple-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-purple-300 font-semibold">Solana</span>
|
||||
</div>
|
||||
<code className="text-xs text-cyan-400 font-mono">{WORKER_SOL}/mcp</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.02] border border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-300 font-semibold">Rugmunch Router</span>
|
||||
<span className="text-[10px] text-gray-500">(legacy)</span>
|
||||
</div>
|
||||
<code className="text-xs text-gray-500 font-mono">https://mcp-router.rugmunch.io/mcp</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Claude Desktop Config</h3>
|
||||
<CodeBlock
|
||||
lang="json"
|
||||
id="claude-config"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"rugmunch-base": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-http"],
|
||||
"env": {
|
||||
"MCP_SERVER_URL": "https://mcp.rugmunch.io/mcp"
|
||||
}
|
||||
},
|
||||
"rugmunch-solana": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-http"],
|
||||
"env": {
|
||||
"MCP_SERVER_URL": "https://mcp.rugmunch.io/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Cursor MCP Setup</h3>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="cursor-mcp"
|
||||
code={`# Add to Cursor MCP settings:
|
||||
{
|
||||
"mcpServers": {
|
||||
"rugmunch-base": {
|
||||
"url": "https://mcp.rugmunch.io/mcp"
|
||||
},
|
||||
"rugmunch-solana": {
|
||||
"url": "https://mcp.rugmunch.io/mcp"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">MCP Tool Call Example</h3>
|
||||
<CodeBlock
|
||||
lang="json"
|
||||
id="mcp-call"
|
||||
code={`// MCP tool call payload (sent via MCP protocol):
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "audit",
|
||||
"arguments": {
|
||||
"address": "So11111111111111111111111111111111111111112",
|
||||
"chain": "solana"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
// The MCP server handles x402 payment automatically
|
||||
// if configured with your wallet credentials.`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Supported AI Agents</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3">
|
||||
{[
|
||||
'Claude Desktop',
|
||||
'ChatGPT',
|
||||
'Cursor',
|
||||
'Windsurf',
|
||||
'Claude Code',
|
||||
'Codex CLI',
|
||||
'OpenCode',
|
||||
'Any MCP Client',
|
||||
].map((name) => (
|
||||
<div key={name} className="p-2 rounded-lg bg-black/20 text-center">
|
||||
<p className="text-xs text-white">{name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Payment Facilitator Cards ── */
|
||||
const PAYMENT_FACILITATORS = [
|
||||
{
|
||||
id: 'coinbase-cdp',
|
||||
name: 'Coinbase CDP',
|
||||
icon: DollarSign,
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-500/20',
|
||||
bg: 'bg-blue-500/5',
|
||||
chains: 'Base, Solana',
|
||||
desc: 'Fee-free USDC payments via Coinbase Developer Platform. Facilitator-verified settlement in <1s.',
|
||||
tag: 'Recommended',
|
||||
},
|
||||
{
|
||||
id: 'payai',
|
||||
name: 'PayAI',
|
||||
icon: Brain,
|
||||
color: 'text-purple-400',
|
||||
border: 'border-purple-500/20',
|
||||
bg: 'bg-purple-500/5',
|
||||
chains: 'Multi-chain',
|
||||
desc: 'Deferred settlement for AI agents. Allows async payment confirmation with guaranteed payout.',
|
||||
tag: 'AI Agents',
|
||||
},
|
||||
{
|
||||
id: 'cloudflare-x402',
|
||||
name: 'Cloudflare x402',
|
||||
icon: Server,
|
||||
color: 'text-orange-400',
|
||||
border: 'border-orange-500/20',
|
||||
bg: 'bg-orange-500/5',
|
||||
chains: 'Base, Solana',
|
||||
desc: 'Cloudflare Worker-based x402 gateway. Edge-verified payment with global low-latency settlement.',
|
||||
tag: 'Edge',
|
||||
},
|
||||
{
|
||||
id: 'eip7702',
|
||||
name: 'EIP-7702',
|
||||
icon: Shield,
|
||||
color: 'text-cyan-400',
|
||||
border: 'border-cyan-500/20',
|
||||
bg: 'bg-cyan-500/5',
|
||||
chains: 'All EVM',
|
||||
desc: 'Universal EVM payment via EIP-7702 code delegation. One signature works across Ethereum, Base, BSC, ARB, OPT, POL.',
|
||||
tag: 'Universal',
|
||||
},
|
||||
{
|
||||
id: 'tron-self-verify',
|
||||
name: 'TRON Self-Verify',
|
||||
icon: CheckCircle,
|
||||
color: 'text-red-400',
|
||||
border: 'border-red-500/20',
|
||||
bg: 'bg-red-500/5',
|
||||
chains: 'TRON',
|
||||
desc: 'Self-verified USDT/USDC/USDD payments on TRON. Client reads on-chain receipt for proof of settlement.',
|
||||
tag: 'Stablecoin',
|
||||
},
|
||||
{
|
||||
id: 'btc-self-verify',
|
||||
name: 'Bitcoin Self-Verify',
|
||||
icon: DollarSign,
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-500/20',
|
||||
bg: 'bg-amber-500/5',
|
||||
chains: 'Bitcoin',
|
||||
desc: 'Self-verified BTC payments. On-chain transaction receipt verified client-side after broadcast.',
|
||||
tag: 'BTC',
|
||||
},
|
||||
{
|
||||
id: 'asterpay',
|
||||
name: 'AsterPay',
|
||||
icon: Globe,
|
||||
color: 'text-green-400',
|
||||
border: 'border-green-500/20',
|
||||
bg: 'bg-green-500/5',
|
||||
chains: 'EUR / SEPA',
|
||||
desc: 'Fiat on-ramp via SEPA bank transfer. EUR-denominated payments for European users and businesses.',
|
||||
tag: 'Fiat',
|
||||
},
|
||||
{
|
||||
id: 'x402-rs',
|
||||
name: 'x402-rs',
|
||||
icon: Terminal,
|
||||
color: 'text-gray-400',
|
||||
border: 'border-white/10',
|
||||
bg: 'bg-white/[0.02]',
|
||||
chains: 'Self-hosted',
|
||||
desc: 'Self-hosted x402 facilitator in Rust. Run your own payment gateway for full sovereignty and custom chains.',
|
||||
tag: 'Self-hosted',
|
||||
},
|
||||
]
|
||||
|
||||
230
src/components/mcp-docs/PaymentFlowSection.tsx
Normal file
230
src/components/mcp-docs/PaymentFlowSection.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { CheckCircle, CreditCard, Lock, ShieldCheck, Terminal, Wallet } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { normalizeChain } from './utils'
|
||||
import CodeBlock from './CodeBlock'
|
||||
|
||||
export default export function PaymentFlowSection() {
|
||||
const steps = [
|
||||
{
|
||||
icon: Wallet,
|
||||
title: 'Connect Wallet',
|
||||
desc: 'Click Connect Wallet in the UI. MetaMask, WalletConnect, Phantom, or any Web3 wallet.',
|
||||
detail: 'Supports all 13 chains: Base, Solana, ETH, BSC, ARB, OPT, POL, AVAX, FTM, GNO, TRX, BTC, SEPA',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'x402 EIP-712 Signing',
|
||||
desc: 'Your wallet signs a USDC transfer authorization. No gas fees, no contract interaction.',
|
||||
detail: 'Base & Solana: facilitator-verified | ETH/BSC/ARB/OPT/POL: self-verification (EIP-3009)',
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: 'Send Request',
|
||||
desc: 'Signed authorization sent in the x-pay header along with the tool request and device fingerprint.',
|
||||
detail: 'Headers: x-pay (signed payload) + X-Device-Id (fingerprint hash)',
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: 'Instant Result',
|
||||
desc: 'Payment verified on-chain, tool executes, results returned immediately.',
|
||||
detail: 'Facilitator chains: <1s | Self-verify chains: <3s',
|
||||
},
|
||||
]
|
||||
|
||||
const [openStep, setOpenStep] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">x402 Payment Flow</h2>
|
||||
<p className="text-gray-400 mb-2">
|
||||
Rug Munch uses the x402 protocol for trustless micropayments across 13 chains. Pay per query with USDC, USDT,
|
||||
BTC, or EUR — no subscriptions, no API keys. 8 facilitators available.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-cyan-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-cyan-300">Facilitator-Verified Chains</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Payment verified server-side by the x402 facilitator. Fastest settlement.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['BASE', 'SOLANA'].map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={`text-xs px-2 py-1 rounded-md font-mono ${
|
||||
c === 'SOLANA' ? 'bg-purple-500/10 text-purple-300' : 'bg-blue-500/10 text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{normalizeChain(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-2">
|
||||
Solana uses SPL-USDC with Phantom signing. Base uses EIP-3009 with MetaMask/WalletConnect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lock className="w-4 h-4 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold text-purple-300">Self-Verification Chains</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Payment verified client-side by reading on-chain receipt. No facilitator needed.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['ETHEREUM', 'BSC', 'ARBITRUM', 'OPTIMISM', 'POLYGON'].map((c) => (
|
||||
<span key={c} className="text-xs px-2 py-1 rounded-md bg-white/5 text-gray-300 font-mono">
|
||||
{normalizeChain(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-2">
|
||||
Uses EIP-3009 TransferWithAuthorization. Your client reads the tx receipt for proof of payment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-8">
|
||||
{steps.map((step, i) => {
|
||||
const Icon = step.icon
|
||||
return (
|
||||
<div key={step.title} className="relative">
|
||||
{i < steps.length - 1 && (
|
||||
<div className="hidden sm:block absolute top-8 left-full w-full h-px bg-gradient-to-r from-purple-500/50 to-transparent z-0" />
|
||||
)}
|
||||
<div
|
||||
className={`relative p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
openStep === i
|
||||
? 'bg-purple-500/10 border-purple-500/30'
|
||||
: 'bg-[#0e0e16] border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
onClick={() => setOpenStep(openStep === i ? null : i)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center mb-3">
|
||||
<Icon className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-white">{step.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{step.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openStep !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-4 rounded-xl bg-[#0e0e16] border border-white/10"
|
||||
>
|
||||
<p className="text-sm text-gray-300">
|
||||
<strong className="text-white">{steps[openStep].title}:</strong> {steps[openStep].detail}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Payment Code Examples</h3>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">JavaScript — Sign & Pay with x402 (Base)</h4>
|
||||
<CodeBlock
|
||||
lang="javascript"
|
||||
id="x402-js-base"
|
||||
code={`import { createWalletClient, custom } from 'viem';
|
||||
import { base } from 'viem/chains';
|
||||
import { databus } from '@/services/databus';
|
||||
|
||||
// Connect wallet
|
||||
const client = createWalletClient({
|
||||
chain: base,
|
||||
transport: custom(window.ethereum),
|
||||
});
|
||||
const [address] = await client.requestAddresses();
|
||||
|
||||
// Sign x402 payment for a $0.05 tool on Base
|
||||
const payment = await signX402Payment('audit', '50000', 8453);
|
||||
|
||||
// Execute tool with payment + device fingerprint
|
||||
const res = await fetch('https://mcp.rugmunch.io/api/v1/x402-tools/audit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-pay': payment.header,
|
||||
'X-Device-Id': getDeviceFingerprint(),
|
||||
},
|
||||
body: JSON.stringify({ address: '0x...', chain: 'base' }),
|
||||
});
|
||||
const result = await res.json();`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">JavaScript — Sign & Pay with x402 (Solana)</h4>
|
||||
<CodeBlock
|
||||
lang="javascript"
|
||||
id="x402-js-sol"
|
||||
code={`import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
|
||||
|
||||
// Connect Phantom wallet
|
||||
const wallet = new PhantomWalletAdapter();
|
||||
await wallet.connect();
|
||||
|
||||
// Sign x402 payment for a $0.05 tool on Solana
|
||||
const payment = await signSolanaX402Payment('audit', '50000', wallet);
|
||||
|
||||
// Execute tool with payment + device fingerprint
|
||||
const res = await fetch('https://mcp.rugmunch.io/api/v1/x402-tools/audit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-pay': payment.header,
|
||||
'X-Device-Id': getDeviceFingerprint(),
|
||||
},
|
||||
body: JSON.stringify({ address: wallet.publicKey, chain: 'solana' }),
|
||||
});
|
||||
const result = await res.json();`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">cURL — Execute Tool with Payment Header</h4>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="x402-curl"
|
||||
code={`# Get the x402 payment header from your wallet signer
|
||||
# Then include it in the request:
|
||||
|
||||
curl -X POST https://mcp.rugmunch.io/api/v1/x402-tools/audit \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "x-pay: x402 {signed-payment-payload}" \\
|
||||
-H "X-Device-Id: {device-fingerprint-hash}" \\
|
||||
-d '{"address": "0x1234...", "chain": "base"}'`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h4 className="text-sm font-semibold text-white mb-2">Free Trial — No Payment Needed</h4>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="trial-curl"
|
||||
code={`# Many tools offer free trial queries.
|
||||
# Just call the endpoint without the x-pay header:
|
||||
|
||||
curl -X POST https://x402-base.cryptorugmuncher.workers.dev/api/v1/x402-tools/urlcheck \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Device-Id: {device-fingerprint-hash}" \\
|
||||
-d '{"url": "https://suspicious-site.com"}'
|
||||
|
||||
# Returns: { "risk_score": 85, "verdict": "SCAM", ... }
|
||||
# After trial limit reached: 402 Payment Required`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
src/components/mcp-docs/PaymentOptionsSection.tsx
Normal file
126
src/components/mcp-docs/PaymentOptionsSection.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { DollarSign, CreditCard, Wallet } from 'lucide-react'
|
||||
|
||||
export function PaymentOptionsSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Payment Options</h2>
|
||||
<p className="text-gray-400">
|
||||
RMI supports 8 payment facilitators across multiple chains and fiat rails. Pay per query with USDC, BTC, EUR, or
|
||||
stablecoins — no subscriptions required.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{PAYMENT_FACILITATORS.map((fac, i) => {
|
||||
const Icon = fac.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={fac.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`p-4 rounded-xl ${fac.bg} border ${fac.border}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${fac.bg} flex items-center justify-center border ${fac.border}`}>
|
||||
<Icon className={`w-4 h-4 ${fac.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{fac.name}</h3>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${fac.bg} ${fac.color} border ${fac.border}`}>
|
||||
{fac.tag}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 font-mono">{fac.chains}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{fac.desc}</p>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-2">How Payments Route</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
When you call a tool endpoint, the x402 protocol automatically routes payment to the correct facilitator based
|
||||
on the chain and payment method in your signed authorization.
|
||||
</p>
|
||||
<div className="space-y-2 text-xs text-gray-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-cyan-400 font-mono shrink-0">Base/SOL</span>
|
||||
<span>→ Coinbase CDP facilitator (fee-free, <1s settlement)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-purple-400 font-mono shrink-0">ETH/BSC/ARB/OPT/POL</span>
|
||||
<span>→ EIP-3009 self-verification (1-5s settlement)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-red-400 font-mono shrink-0">TRON</span>
|
||||
<span>→ Self-verified USDT/USDC/USDD transfer</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-amber-400 font-mono shrink-0">BTC</span>
|
||||
<span>→ Self-verified on-chain transaction</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-green-400 font-mono shrink-0">EUR/SEPA</span>
|
||||
<span>→ AsterPay fiat on-ramp</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── FAQ Accordion Section ── */
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'What is Rug Munch Intelligence (RMI)?',
|
||||
a: 'RMI is an AI-powered crypto security and market intelligence platform offering 201+ tools via REST API and Model Context Protocol (MCP). Tools cover security audits, wallet profiling, whale tracking, DeFi scanning, social sentiment, and more across 13 blockchains.',
|
||||
},
|
||||
{
|
||||
q: 'What is Model Context Protocol (MCP)?',
|
||||
a: 'MCP is an open standard that enables AI agents like Claude, ChatGPT, and Cursor to discover and call external tools. RMI exposes its full tool catalog via MCP endpoints — your AI agent can call any of the 201 tools directly without writing custom integration code.',
|
||||
},
|
||||
{
|
||||
q: 'How does x402 payment work?',
|
||||
a: 'x402 is an HTTP 402 Payment Required protocol for micropayments. Your wallet signs a USDC transfer authorization (EIP-712 on EVM, SPL transfer on Solana). The signed authorization goes in the x-pay header. The facilitator verifies on-chain and settles — no gas fees, no contract approvals needed.',
|
||||
},
|
||||
{
|
||||
q: 'Which wallets are supported?',
|
||||
a: 'MetaMask, Phantom, Solflare, Coinbase Wallet, Rainbow, WalletConnect, TronLink, and Backpack are all supported. Any wallet that injects window.ethereum (EVM) or window.solana (Solana) works — including Rabby, Trust Wallet, and other Web3 wallets.',
|
||||
},
|
||||
{
|
||||
q: 'Which chains does RMI support?',
|
||||
a: 'RMI supports 13 chains: Base, Solana, Ethereum, BSC, Arbitrum, Optimism, Polygon, Avalanche, Fantom, Gnosis, TRON, Bitcoin, and SEPA (EUR). 8 payment facilitators cover all chains — Coinbase CDP (fee-free), EIP-7702 (universal EVM), TRON Self-Verify, Bitcoin Self-Verify, and AsterPay (EUR/SEPA).',
|
||||
},
|
||||
{
|
||||
q: 'Are there free trials?',
|
||||
a: "Yes! Every tool offers a free trial: 1 free query with device fingerprint only, 3 free queries with a connected wallet. Just include the X-Device-Id header — no API key or credit card required. After trial limits are reached, you'll get a 402 Payment Required response.",
|
||||
},
|
||||
{
|
||||
q: 'What is the refund policy?',
|
||||
a: 'Full refund if a tool returns no data, an empty result, or a backend error. Refunds are automatic on facilitator chains (<30s). Self-verify chains refund in 1-5 minutes. You can also manually request a refund via the /api/v1/x402/refund endpoint.',
|
||||
},
|
||||
{
|
||||
q: 'How do I connect RMI to Claude or Cursor?',
|
||||
a: 'Add the MCP server URL to your Claude Desktop config or Cursor MCP settings. Use https://mcp.rugmunch.io/mcp for all tools (both EVM and Solana). See the MCP Integration section for full config examples.',
|
||||
},
|
||||
{
|
||||
q: 'What is device fingerprinting?',
|
||||
a: 'Every request must include an X-Device-Id header containing a SHA-256 hash of browser Canvas, WebGL renderer, and font data. This uniquely identifies your browser without requiring accounts. It enforces trial limits and rate limits to prevent abuse.',
|
||||
},
|
||||
{
|
||||
q: 'Can I run my own facilitator?',
|
||||
a: 'Yes. The x402-rs facilitator (written in Rust) lets you run your own payment gateway for full sovereignty. It supports custom chain configurations and can be self-hosted. See the Payment Options section for details.',
|
||||
},
|
||||
{
|
||||
q: 'How do I find available tools programmatically?',
|
||||
a: 'Use the discovery endpoints: GET /.well-known/x402 for the x402 standard config, or GET /api/v1/x402/tools-catalog for the full catalog with metadata. Both return machine-readable JSON suitable for AI agents and CI/CD pipelines.',
|
||||
},
|
||||
{
|
||||
q: 'What happens after my free trial expires?',
|
||||
a: 'After exceeding your free trial queries (1 per tool fingerprint-only, 3 per tool wallet-connected), subsequent calls return HTTP 402 Payment Required with the price in the response body. You then need to include an x-pay header with a signed USDC authorization.',
|
||||
},
|
||||
]
|
||||
104
src/components/mcp-docs/RefundPolicySection.tsx
Normal file
104
src/components/mcp-docs/RefundPolicySection.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { RotateCcw, DollarSign, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function RefundPolicySection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Refund Policy</h2>
|
||||
<p className="text-gray-400">Full refund if no data is returned. We stand behind every query.</p>
|
||||
|
||||
{/* Refund guarantee callout */}
|
||||
<div className="p-5 rounded-xl bg-gradient-to-r from-green-500/5 to-cyan-500/5 border border-green-500/20">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<RotateCcw className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">No Data = Full Refund</h3>
|
||||
<p className="text-sm text-gray-400">Automatic refund if the tool returns empty or error responses</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
If a paid tool execution returns no data (empty result, null, or an error that is our fault), your USDC
|
||||
payment is automatically refunded. No support ticket needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Refund rules */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<h3 className="text-sm font-semibold text-green-300">Refunded</h3>
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-xs text-gray-400">
|
||||
<li>Tool returns empty or null data</li>
|
||||
<li>Backend error (5xx) before execution</li>
|
||||
<li>Tool times out before returning results</li>
|
||||
<li>Data source returns malformed response</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-red-300">Not Refunded</h3>
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-xs text-gray-400">
|
||||
<li>Valid data returned (even if unfavorable)</li>
|
||||
<li>Invalid input parameters from client</li>
|
||||
<li>Rate-limited or blocked (abuse)</li>
|
||||
<li>Payment signature expired or invalid</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refund API endpoint */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Refund API</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Refunds are automatically initiated by the backend when no data is returned. You can also manually request a
|
||||
refund:
|
||||
</p>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="refund-api"
|
||||
code={`# Request a refund for a tool execution
|
||||
# Requires the original transaction hash
|
||||
|
||||
curl -X POST https://rugmunch.io/api/v1/x402/refund \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tx_hash": "0xabc123...",
|
||||
"tool_id": "audit",
|
||||
"reason": "no_data_returned"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "status": "refunded",
|
||||
# "amount": "0.05",
|
||||
# "refund_tx": "0xdef456...",
|
||||
# "chain": "base"
|
||||
# }`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Refund timeline */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Refund Timeline</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Facilitator chains (Base, Solana)', time: '< 30 seconds', color: 'text-cyan-400' },
|
||||
{ label: 'Self-verify chains (ETH, BSC, ARB, OPT, POL)', time: '1-5 minutes', color: 'text-purple-400' },
|
||||
{ label: 'Disputed refunds', time: '24-48 hours', color: 'text-amber-400' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-lg bg-black/20">
|
||||
<span className="text-xs text-gray-300">{item.label}</span>
|
||||
<span className={`text-xs font-mono ${item.color}`}>{item.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
165
src/components/mcp-docs/ToolDocCard.tsx
Normal file
165
src/components/mcp-docs/ToolDocCard.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, ChevronRight, Globe } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
capitalizeName,
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
generateCurlExample,
|
||||
generateJSExample,
|
||||
generatePythonExample,
|
||||
generateX402Example,
|
||||
getWorkerUrl,
|
||||
normalizeChain,
|
||||
type ToolCatalogEntry,
|
||||
} from './utils'
|
||||
import CodeBlock from './CodeBlock'
|
||||
|
||||
export default export function ToolDocCard({ tool, index }: { tool: ToolCatalogEntry; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'curl' | 'python' | 'js' | 'x402'>('curl')
|
||||
const Icon = CATEGORY_ICONS[tool.category] || Globe
|
||||
const catColor = CATEGORY_COLORS[tool.category] || 'text-gray-400'
|
||||
|
||||
const curlEx = generateCurlExample(tool)
|
||||
const pyEx = generatePythonExample(tool)
|
||||
const jsEx = generateJSExample(tool)
|
||||
const x402Ex = generateX402Example(tool)
|
||||
|
||||
const workerUrl = getWorkerUrl(tool.chains[0] || 'base')
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.01, 0.5) }}
|
||||
className="rounded-xl bg-[#0e0e16] border border-white/5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full text-left p-4 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Icon className={`w-5 h-5 ${catColor} mt-0.5 shrink-0`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-mono text-sm font-bold text-white">{tool.id}</h3>
|
||||
<span className="text-xs text-gray-500">{tool.method}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-300`}>{tool.price}</span>
|
||||
{tool.trialFree > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">
|
||||
{tool.trialFree} free
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{tool.chains.slice(0, 4).map((c) => (
|
||||
<span key={c} className="text-[10px] px-1 py-0.5 rounded bg-white/5 text-gray-500">
|
||||
{normalizeChain(c)}
|
||||
</span>
|
||||
))}
|
||||
{tool.chains.length > 4 && <span className="text-[10px] text-gray-600">+{tool.chains.length - 4}</span>}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-4 border-t border-white/5 pt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-black/20">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Service</p>
|
||||
<p className="text-xs text-white font-mono mt-0.5">{tool.service}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-black/20">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Category</p>
|
||||
<p className="text-xs text-white mt-0.5">{capitalizeName(tool.category)}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-black/20">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Source</p>
|
||||
<p className="text-xs text-white mt-0.5">
|
||||
{tool.source === 'native' ? 'RMI Native' : 'External API'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-black/20">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Free Trials</p>
|
||||
<p className="text-xs text-green-400 mt-0.5">{tool.trialFree} queries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mb-2">Supported Chains</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tool.chains.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={`text-xs px-2 py-1 rounded-md font-mono ${
|
||||
c === 'SOLANA'
|
||||
? 'bg-purple-500/10 text-purple-300'
|
||||
: c === 'BASE'
|
||||
? 'bg-blue-500/10 text-blue-300'
|
||||
: 'bg-white/5 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{normalizeChain(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mb-2">Code Examples</p>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(['curl', 'python', 'js', 'x402'] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
|
||||
: 'text-gray-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab === 'curl' ? 'cURL' : tab === 'python' ? 'Python' : tab === 'js' ? 'JavaScript' : 'x402 Pay'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === 'curl' && <CodeBlock code={curlEx} lang="bash" id={`${tool.id}-curl`} />}
|
||||
{activeTab === 'python' && <CodeBlock code={pyEx} lang="python" id={`${tool.id}-py`} />}
|
||||
{activeTab === 'js' && <CodeBlock code={jsEx} lang="javascript" id={`${tool.id}-js`} />}
|
||||
{activeTab === 'x402' && <CodeBlock code={x402Ex} lang="javascript" id={`${tool.id}-x402`} />}
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-black/20 border border-white/5">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mb-1">Endpoint</p>
|
||||
<code className="text-xs text-cyan-400 font-mono break-all">
|
||||
{tool.method === 'POST' ? 'POST' : 'GET'} {workerUrl}/api/v1/x402-tools/{tool.id}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
116
src/components/mcp-docs/TrialSystemSection.tsx
Normal file
116
src/components/mcp-docs/TrialSystemSection.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Fingerprint, Wallet, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from '@/components/mcp-docs/CodeBlock'
|
||||
import type { ToolCatalogEntry } from '@/components/mcp-docs/utils'
|
||||
|
||||
export function TrialSystemSection({ tools }: { tools: ToolCatalogEntry[] }) {
|
||||
const trialStats = useMemo(() => {
|
||||
const withTrials = tools.filter((t) => t.trialFree > 0)
|
||||
const totalFree = withTrials.reduce((sum, t) => sum + t.trialFree, 0)
|
||||
const byCount: Record<number, number> = {}
|
||||
tools.forEach((t) => {
|
||||
byCount[t.trialFree] = (byCount[t.trialFree] || 0) + 1
|
||||
})
|
||||
return { withTrials: withTrials.length, totalFree, byCount, all: tools.length }
|
||||
}, [tools])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Free Trial System</h2>
|
||||
<p className="text-gray-400">
|
||||
Every tool offers a free trial — 1 free query with device fingerprint only, 3 free with a connected wallet.
|
||||
Trial counts reset periodically.
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10 text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{trialStats.withTrials}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Tools with Free Trials</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10 text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{trialStats.totalFree}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Total Free Queries</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10 text-center">
|
||||
<p className="text-3xl font-bold text-purple-400">{Object.keys(trialStats.byCount).length}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Trial Tiers</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10 text-center">
|
||||
<p className="text-3xl font-bold text-gray-400">{trialStats.all || 103}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Total Tools</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trial tier hierarchy */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Fingerprint className="w-4 h-4 text-green-400" />
|
||||
<h3 className="text-sm font-semibold text-green-300">Device Fingerprint Only</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-1">No wallet needed. 1 free query per tool.</p>
|
||||
<p className="text-[10px] text-gray-500">Tracked via X-Device-Id header (canvas/WebGL/font hash)</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-cyan-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wallet className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-cyan-300">Connected Wallet</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-1">3 free queries per tool when wallet is connected.</p>
|
||||
<p className="text-[10px] text-gray-500">Higher trust tier = more free queries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How trials work */}
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">How Trials Work</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 shrink-0" /> Call any tool endpoint without an x-pay
|
||||
header
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 shrink-0" /> Include X-Device-Id header for trial
|
||||
tracking
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Fingerprint className="w-4 h-4 text-green-400 mt-0.5 shrink-0" /> Fingerprint-only: 1 free query per tool
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Wallet className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0" /> Connected wallet: 3 free queries per tool
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 shrink-0" /> After trial limit: backend returns 402
|
||||
Payment Required
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" /> No credit card, no signup, no API key needed
|
||||
for trials
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Try a free tool */}
|
||||
<div className="p-4 rounded-xl bg-green-500/5 border border-green-500/20">
|
||||
<h3 className="text-sm font-semibold text-green-300 mb-2">Try a Free Tool Now</h3>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
id="free-tool"
|
||||
code={`# No payment needed — just include device fingerprint:
|
||||
# X-Device-Id is a hash of canvas/WebGL/font data from your browser.
|
||||
|
||||
curl -X POST https://mcp.rugmunch.io/api/v1/x402-tools/urlcheck \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Device-Id: \${YOUR_DEVICE_FINGERPRINT}" \\
|
||||
-d '{"url": "https://suspicious-site.com"}'
|
||||
|
||||
# Or try market data (no params needed):
|
||||
curl https://mcp.rugmunch.io/api/v1/x402-tools/pulse \\
|
||||
-H "X-Device-Id: \${YOUR_DEVICE_FINGERPRINT}"`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
165
src/components/mcp-docs/WalletConnectionSection.tsx
Normal file
165
src/components/mcp-docs/WalletConnectionSection.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { Wallet } from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
export default export function WalletConnectionSection() {
|
||||
const { walletAddress, walletProvider, walletLogin, disconnectWallet, isAuthenticated } = useAuth()
|
||||
|
||||
const wallets = [
|
||||
{
|
||||
name: 'MetaMask',
|
||||
icon: '🦊',
|
||||
desc: 'Browser extension. EVM chains (Base, ETH, BSC, ARB, OPT, POL).',
|
||||
provider: 'metamask',
|
||||
},
|
||||
{ name: 'Phantom', icon: '👻', desc: 'Solana + multi-chain. Built-in swap & NFT support.', provider: 'phantom' },
|
||||
{
|
||||
name: 'Solflare',
|
||||
icon: '☀️',
|
||||
desc: 'Solana-focused wallet with staking and DeFi features.',
|
||||
provider: 'solflare',
|
||||
},
|
||||
{
|
||||
name: 'Coinbase Wallet',
|
||||
icon: '🔵',
|
||||
desc: 'Self-custody. Multi-chain — Base, ETH, Solana.',
|
||||
provider: 'coinbase',
|
||||
},
|
||||
{ name: 'Rainbow', icon: '🌈', desc: 'Mobile-first EVM wallet. Clean UX, multi-chain.', provider: 'rainbow' },
|
||||
{
|
||||
name: 'WalletConnect',
|
||||
icon: '🔗',
|
||||
desc: 'QR code scan. 300+ mobile wallets supported.',
|
||||
provider: 'walletconnect',
|
||||
},
|
||||
{ name: 'TronLink', icon: '🔴', desc: 'TRON ecosystem wallet. USDT/USDC/USDD on TRON.', provider: null },
|
||||
{ name: 'Backpack', icon: '🎒', desc: 'Solana + EVM. xNFT support, multi-chain.', provider: 'backpack' },
|
||||
]
|
||||
|
||||
const supportedChains = [
|
||||
{ name: 'Base', id: 8453, mode: 'Facilitator', color: 'bg-blue-500/20 text-blue-300' },
|
||||
{ name: 'Solana', id: '—', mode: 'Facilitator', color: 'bg-purple-500/20 text-purple-300' },
|
||||
{ name: 'Ethereum', id: 1, mode: 'Self-Verify', color: 'bg-gray-500/20 text-gray-300' },
|
||||
{ name: 'BSC', id: 56, mode: 'Self-Verify', color: 'bg-yellow-500/20 text-yellow-300' },
|
||||
{ name: 'Arbitrum', id: 42161, mode: 'Self-Verify', color: 'bg-sky-500/20 text-sky-300' },
|
||||
{ name: 'Optimism', id: 10, mode: 'Self-Verify', color: 'bg-red-500/20 text-red-300' },
|
||||
{ name: 'Polygon', id: 137, mode: 'Self-Verify', color: 'bg-indigo-500/20 text-indigo-300' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Wallet Connection</h2>
|
||||
<p className="text-gray-400">
|
||||
Connect any Web3 wallet to pay for tools with USDC across 13 chains. The wallet signs EIP-712 or Solana
|
||||
authorizations — no gas, no contract approvals needed.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{wallets.map((w) => {
|
||||
const isConnected = isAuthenticated && walletProvider === w.provider
|
||||
return (
|
||||
<div
|
||||
key={w.name}
|
||||
className={`p-3 rounded-xl bg-[#0e0e16] border transition-colors ${isConnected ? 'border-green-500/30' : 'border-white/5'}`}
|
||||
>
|
||||
<div className="text-xl mb-1">{w.icon}</div>
|
||||
<p className="text-sm font-medium text-white">{w.name}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{w.desc}</p>
|
||||
{w.provider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isConnected ? disconnectWallet() : walletLogin(w.provider as any))}
|
||||
className={`mt-2 w-full text-xs py-1.5 px-2 rounded-lg transition-colors ${
|
||||
isConnected
|
||||
? 'bg-green-500/10 border border-green-500/20 text-green-400 hover:bg-green-500/20'
|
||||
: 'bg-purple-500/10 border border-purple-500/20 text-purple-300 hover:bg-purple-500/20'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? 'Disconnect' : 'Connect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{walletAddress && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-4 rounded-xl bg-green-500/5 border border-green-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<Wallet className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-300">Wallet Connected</p>
|
||||
<p className="text-xs text-gray-400 font-mono">
|
||||
{walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">Provider: {walletProvider}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={disconnectWallet}
|
||||
className="ml-auto text-xs text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Supported Payment Chains (7)</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{supportedChains.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-mono ${c.color}`}>{c.name}</span>
|
||||
{c.id !== '—' && <span className="text-[10px] text-gray-600 font-mono">ID: {c.id}</span>}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
c.mode === 'Facilitator' ? 'bg-cyan-500/10 text-cyan-400' : 'bg-purple-500/10 text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{c.mode}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-[#0e0e16] border border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">How Wallet Connection Works</h3>
|
||||
<ol className="space-y-2 text-sm text-gray-300">
|
||||
<li className="flex gap-2">
|
||||
<span className="text-purple-400 font-mono">1.</span> Click "Connect Wallet" — your wallet prompts for
|
||||
account access
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-purple-400 font-mono">2.</span> When executing a tool, a payment signature is
|
||||
requested — no gas fee
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-purple-400 font-mono">3.</span> Solana: Phantom signs SPL-USDC transfer. EVM: EIP-3009
|
||||
TransferWithAuthorization
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-purple-400 font-mono">4.</span> The signed authorization is sent with your tool
|
||||
request + device fingerprint
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-purple-400 font-mono">5.</span> Backend verifies (facilitator or self-verify),
|
||||
processes USDC transfer, returns results
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
src/components/mcp-docs/utils.ts
Normal file
237
src/components/mcp-docs/utils.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import {
|
||||
BarChart3,
|
||||
Code,
|
||||
Crosshair,
|
||||
Crown,
|
||||
Database,
|
||||
Eye,
|
||||
Globe,
|
||||
Package,
|
||||
Scan,
|
||||
Shield,
|
||||
Target,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { formatPayload, generateSamplePayload } from '@/data/tool-params'
|
||||
|
||||
export interface ToolCatalogEntry {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
priceUsd: number
|
||||
category: string
|
||||
trialFree: number
|
||||
method: string
|
||||
service: string
|
||||
source: string
|
||||
chains: string[]
|
||||
}
|
||||
|
||||
export interface ToolCatalogResponse {
|
||||
tools: ToolCatalogEntry[]
|
||||
total_tools: number
|
||||
total_chains: number
|
||||
total_services: number
|
||||
chains: Record<string, number>
|
||||
}
|
||||
|
||||
export const CHAIN_DISPLAY: Record<string, string> = {
|
||||
SOLANA: 'SOL',
|
||||
ETHEREUM: 'ETH',
|
||||
BASE: 'BASE',
|
||||
BSC: 'BSC',
|
||||
ARBITRUM: 'ARB',
|
||||
OPTIMISM: 'OPT',
|
||||
POLYGON: 'POL',
|
||||
AVALANCHE: 'AVAX',
|
||||
FANTOM: 'FTM',
|
||||
GNOSIS: 'GNO',
|
||||
TRON: 'TRX',
|
||||
BITCOIN: 'BTC',
|
||||
SEPA: 'SEPA',
|
||||
}
|
||||
|
||||
export function normalizeChain(c: string): string {
|
||||
return CHAIN_DISPLAY[c] || c
|
||||
}
|
||||
|
||||
export const CATEGORIES: { id: string; label: string; icon: LucideIcon; color: string }[] = [
|
||||
{ id: 'all', label: 'All', icon: Globe, color: 'text-gray-300' },
|
||||
{ id: 'security', label: 'Security', icon: Shield, color: 'text-red-400' },
|
||||
{ id: 'intelligence', label: 'Intelligence', icon: Eye, color: 'text-purple-400' },
|
||||
{ id: 'analysis', label: 'Analysis', icon: Scan, color: 'text-teal-400' },
|
||||
{ id: 'market', label: 'Market', icon: BarChart3, color: 'text-green-400' },
|
||||
{ id: 'social', label: 'Social', icon: Users, color: 'text-pink-400' },
|
||||
{ id: 'launchpad', label: 'Launchpad', icon: Zap, color: 'text-yellow-400' },
|
||||
{ id: 'forensics', label: 'Forensics', icon: Crosshair, color: 'text-rose-400' },
|
||||
{ id: 'defi', label: 'DeFi', icon: Database, color: 'text-blue-400' },
|
||||
{ id: 'nft', label: 'NFT', icon: Target, color: 'text-orange-400' },
|
||||
{ id: 'premium', label: 'Premium', icon: Crown, color: 'text-amber-400' },
|
||||
{ id: 'bundle', label: 'Bundle', icon: Package, color: 'text-violet-400' },
|
||||
{ id: 'api', label: 'API', icon: Code, color: 'text-cyan-400' },
|
||||
]
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
security: Shield,
|
||||
intelligence: Eye,
|
||||
analysis: Scan,
|
||||
market: BarChart3,
|
||||
social: Users,
|
||||
launchpad: Zap,
|
||||
forensics: Crosshair,
|
||||
defi: Database,
|
||||
nft: Target,
|
||||
premium: Crown,
|
||||
bundle: Package,
|
||||
api: Code,
|
||||
}
|
||||
|
||||
export const CATEGORY_COLORS: Record<string, string> = {
|
||||
security: 'text-red-400',
|
||||
intelligence: 'text-purple-400',
|
||||
analysis: 'text-teal-400',
|
||||
market: 'text-green-400',
|
||||
social: 'text-pink-400',
|
||||
launchpad: 'text-yellow-400',
|
||||
forensics: 'text-rose-400',
|
||||
defi: 'text-blue-400',
|
||||
nft: 'text-orange-400',
|
||||
premium: 'text-amber-400',
|
||||
bundle: 'text-violet-400',
|
||||
api: 'text-cyan-400',
|
||||
}
|
||||
|
||||
export function capitalizeName(name: string): string {
|
||||
return name
|
||||
.split(/[_\s]+/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const _API_BASE = 'https://mcp.rugmunch.io/api/v1/x402-tools'
|
||||
export const WORKER_BASE = 'https://x402-base.cryptorugmuncher.workers.dev'
|
||||
export const WORKER_SOL = 'https://x402-sol.cryptorugmuncher.workers.dev'
|
||||
export const _MARKETPLACE_URL = 'https://x402.rugmunch.io'
|
||||
export const _MCP_ENDPOINT = 'https://mcp.rugmunch.io/mcp'
|
||||
export const _X402_DISCOVERY = 'https://mcp.rugmunch.io/.well-known/x402'
|
||||
|
||||
export const PREFERRED_API = 'https://mcp.rugmunch.io/api/v1/x402-tools'
|
||||
|
||||
export function _getPreferredApiUrl(_chain: string): string {
|
||||
return PREFERRED_API
|
||||
}
|
||||
|
||||
export function getWorkerUrl(chain: string): string {
|
||||
const normalized = chain.toUpperCase()
|
||||
if (normalized === 'SOLANA' || normalized === 'SOL') return WORKER_SOL
|
||||
return WORKER_BASE
|
||||
}
|
||||
|
||||
export function getApiUrl(toolId: string): string {
|
||||
return `${PREFERRED_API}/${toolId}`
|
||||
}
|
||||
|
||||
export function generateCurlExample(tool: ToolCatalogEntry): string {
|
||||
const url = getApiUrl(tool.id)
|
||||
const payload = generateSamplePayload(tool.id)
|
||||
const hasParams = Object.keys(payload).length > 0
|
||||
const formattedPayload = hasParams ? formatPayload(payload) : '{}'
|
||||
|
||||
if (tool.method === 'POST') {
|
||||
return `# Recommended: Use mcp.rugmunch.io (all chains)
|
||||
curl -X POST ${url} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Device-Id: \${YOUR_DEVICE_ID}" \\
|
||||
-d '${formattedPayload}'
|
||||
|
||||
# Alternative: x402 Worker (chain-specific)
|
||||
curl -X POST ${getWorkerUrl(tool.chains[0] || 'base')}/api/v1/x402-tools/${tool.id} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Device-Id: \${YOUR_DEVICE_ID}" \\
|
||||
-d '${formattedPayload}'`
|
||||
}
|
||||
const qs = new URLSearchParams(payload).toString()
|
||||
return `curl "${url}${qs ? `?${qs}` : ''}" \\
|
||||
-H "X-Device-Id: \${YOUR_DEVICE_ID}"`
|
||||
}
|
||||
|
||||
export function generatePythonExample(tool: ToolCatalogEntry): string {
|
||||
const url = getApiUrl(tool.id)
|
||||
const payload = generateSamplePayload(tool.id)
|
||||
|
||||
if (tool.method === 'POST') {
|
||||
return `import requests
|
||||
|
||||
# Recommended: mcp.rugmunch.io (all chains, x402 protocol)
|
||||
url = "${url}"
|
||||
headers = {"X-Device-Id": "your-device-fingerprint"}
|
||||
payload = ${JSON.stringify(payload, null, 2)
|
||||
.replace(/"(.+)"/g, '"$1"')
|
||||
.replace(/^( {2})/gm, ' ')}
|
||||
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
print(response.json())`
|
||||
}
|
||||
return `import requests
|
||||
|
||||
url = "${url}"
|
||||
headers = {"X-Device-Id": "your-device-fingerprint"}
|
||||
params = ${JSON.stringify(payload, null, 2)
|
||||
.replace(/"(.+)"/g, '"$1"')
|
||||
.replace(/^( {2})/gm, ' ')}
|
||||
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
print(response.json())`
|
||||
}
|
||||
|
||||
export function generateJSExample(tool: ToolCatalogEntry): string {
|
||||
const url = getApiUrl(tool.id)
|
||||
const payload = generateSamplePayload(tool.id)
|
||||
|
||||
if (tool.method === 'POST') {
|
||||
return `// Recommended: mcp.rugmunch.io (all chains, x402 protocol)
|
||||
const response = await fetch('${url}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': getDeviceFingerprint(),
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload, null, 2)})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);`
|
||||
}
|
||||
const _qs = new URLSearchParams(payload).toString()
|
||||
return `const response = await databus.fetch('mcp_bridge', { url, params: qs }) ? '?' + qs : ''}', {
|
||||
headers: { 'X-Device-Id': getDeviceFingerprint() }
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(data);`
|
||||
}
|
||||
|
||||
export function generateX402Example(tool: ToolCatalogEntry): string {
|
||||
const workerUrl = getWorkerUrl(tool.chains[0] || 'base')
|
||||
const url = `${workerUrl}/api/v1/x402-tools/${tool.id}`
|
||||
const payload = generateSamplePayload(tool.id)
|
||||
const formattedPayload = formatPayload(payload)
|
||||
const primaryChain = tool.chains[0] || 'BASE'
|
||||
const chainId = primaryChain === 'SOLANA' ? 'solana' : '8453'
|
||||
|
||||
return `// Sign x402 payment for ${tool.name} (${tool.price}) on ${normalizeChain(primaryChain)}
|
||||
const payment = await signX402Payment('${tool.id}', '${tool.price}', '${chainId}');
|
||||
|
||||
const res = await fetch('${url}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-pay': payment.header,
|
||||
'X-Device-Id': getDeviceFingerprint(),
|
||||
},
|
||||
body: JSON.stringify(${formattedPayload}),
|
||||
});
|
||||
const result = await res.json();`
|
||||
}
|
||||
172
src/components/profile/BadgesTab.tsx
Normal file
172
src/components/profile/BadgesTab.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
export function BadgesTab({ profile }: { profile: Profile }) {
|
||||
const ALL_BADGES = [
|
||||
{
|
||||
id: 'early_adopter',
|
||||
name: 'Early Adopter',
|
||||
desc: 'Joined during beta',
|
||||
icon: '🚀',
|
||||
color: 'bg-purple-500/20 text-purple-400',
|
||||
},
|
||||
{
|
||||
id: 'wallet_master',
|
||||
name: 'Wallet Master',
|
||||
desc: 'Linked 3+ wallets',
|
||||
icon: '💼',
|
||||
color: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
{
|
||||
id: 'security_expert',
|
||||
name: 'Security Expert',
|
||||
desc: 'Scanned 50+ tokens',
|
||||
icon: '🛡️',
|
||||
color: 'bg-green-500/20 text-green-400',
|
||||
},
|
||||
{
|
||||
id: 'whale_watcher',
|
||||
name: 'Whale Watcher',
|
||||
desc: 'Tracked 100+ whales',
|
||||
icon: '🐋',
|
||||
color: 'bg-cyan-500/20 text-cyan-400',
|
||||
},
|
||||
{
|
||||
id: 'rug_survivor',
|
||||
name: 'Rug Survivor',
|
||||
desc: 'Avoided 5+ scams',
|
||||
icon: '⚡',
|
||||
color: 'bg-yellow-500/20 text-yellow-400',
|
||||
},
|
||||
{
|
||||
id: 'community_hero',
|
||||
name: 'Community Hero',
|
||||
desc: 'Helped 10+ users',
|
||||
icon: '🤝',
|
||||
color: 'bg-pink-500/20 text-pink-400',
|
||||
},
|
||||
{
|
||||
id: 'data_diver',
|
||||
name: 'Data Diver',
|
||||
desc: 'Used 20+ DataBus tools',
|
||||
icon: '📊',
|
||||
color: 'bg-indigo-500/20 text-indigo-400',
|
||||
},
|
||||
{
|
||||
id: 'premium_member',
|
||||
name: 'Premium',
|
||||
desc: 'Upgraded to paid tier',
|
||||
icon: '👑',
|
||||
color: 'bg-yellow-500/20 text-yellow-400',
|
||||
},
|
||||
{
|
||||
id: 'verified_bot',
|
||||
name: 'Verified Bot',
|
||||
desc: 'Active x402 bot member',
|
||||
icon: '🤖',
|
||||
color: 'bg-emerald-500/20 text-emerald-400',
|
||||
},
|
||||
{
|
||||
id: 'scam_hunter',
|
||||
name: 'Scam Hunter',
|
||||
desc: 'Reported 10+ confirmed scams',
|
||||
icon: '🎯',
|
||||
color: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
]
|
||||
|
||||
// Support both legacy string[] and new JSONB object array
|
||||
const earnedIds = new Set(
|
||||
Array.isArray(profile.badges) ? profile.badges.map((b: any) => (typeof b === 'string' ? b : b.id)) : [],
|
||||
)
|
||||
|
||||
const earnedBadgesObj = Array.isArray(profile.badges)
|
||||
? profile.badges.filter((b: any) => typeof b === 'object' && b !== null)
|
||||
: []
|
||||
|
||||
const xpProgress = ((profile.xp % 1000) / 1000) * 100
|
||||
const xpForNext = 1000 - (profile.xp % 1000)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* XP Progress */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Level {profile.level}</h3>
|
||||
<p className="text-sm text-gray-500">{xpForNext} XP to next level</p>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-yellow-400">
|
||||
{profile.xp}
|
||||
<span className="text-sm text-gray-500 font-normal"> XP</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-[#1a1a2e] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-cyan-500 rounded-full transition-all"
|
||||
style={{ width: `${xpProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Earned Badges (JSONB) */}
|
||||
{earnedBadgesObj.length > 0 && (
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
Earned Achievements
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{earnedBadgesObj.map((badge: any, i: number) => (
|
||||
<div
|
||||
key={badge.id || i}
|
||||
className="p-4 rounded-xl border border-white/10 bg-[#1a1a2e] text-center transition-all hover:scale-105"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl mx-auto mb-2 flex items-center justify-center text-2xl ${badge.color || 'bg-purple-500/20 text-purple-400'}`}
|
||||
>
|
||||
{badge.icon || '🏆'}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white">{badge.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{badge.description || badge.desc}</div>
|
||||
<div className="mt-2 text-[10px] text-gray-600">
|
||||
{badge.awarded_at ? new Date(badge.awarded_at).toLocaleDateString() : 'Earned'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Badges Grid */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">All Achievements</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{ALL_BADGES.map((badge) => {
|
||||
const isEarned = earnedIds.has(badge.id)
|
||||
return (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={`p-4 rounded-xl border text-center transition-all ${
|
||||
isEarned ? 'border-white/10 bg-[#1a1a2e]' : 'border-white/5 bg-[#0e0e16] opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl mx-auto mb-2 flex items-center justify-center text-2xl ${isEarned ? badge.color : 'bg-gray-500/10 text-gray-600'}`}
|
||||
>
|
||||
{badge.icon}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white">{badge.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{badge.desc}</div>
|
||||
{isEarned && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs text-green-400">
|
||||
<Check className="w-3 h-3" /> Earned
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Privacy Tab ──
|
||||
92
src/components/profile/NotificationsTab.tsx
Normal file
92
src/components/profile/NotificationsTab.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export function NotificationsTab() {
|
||||
const [prefs, setPrefs] = useState({
|
||||
price_alerts: true,
|
||||
security_alerts: true,
|
||||
new_features: false,
|
||||
newsletter: false,
|
||||
product_updates: true,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
apiFetch('/api/v1/profile/notifications', { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
})
|
||||
|
||||
const toggle = (key: keyof typeof prefs) => {
|
||||
const next = { ...prefs, [key]: !prefs[key] }
|
||||
setPrefs(next)
|
||||
updateMutation.mutate(next)
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'price_alerts' as const,
|
||||
label: 'Price Alerts',
|
||||
desc: 'Get notified when tokens hit your target prices',
|
||||
icon: <Bell className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
key: 'security_alerts' as const,
|
||||
label: 'Security Alerts',
|
||||
desc: 'Rug pulls, exploits, and scam warnings',
|
||||
icon: <Shield className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
key: 'product_updates' as const,
|
||||
label: 'Product Updates',
|
||||
desc: 'New features and improvements',
|
||||
icon: <Star className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
key: 'new_features' as const,
|
||||
label: 'Beta Features',
|
||||
desc: 'Early access to experimental tools',
|
||||
icon: <Crown className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
key: 'newsletter' as const,
|
||||
label: 'Weekly Newsletter',
|
||||
desc: 'Digest of top crypto intelligence',
|
||||
icon: <MessageCircle className="w-4 h-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Notification Preferences</h3>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between bg-[#1a1a2e] rounded-lg p-4 border border-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center text-purple-400">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-white font-medium">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(item.key)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${
|
||||
prefs[item.key] ? 'bg-purple-600' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full bg-white absolute top-1 transition-transform ${
|
||||
prefs[item.key] ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Badges Tab ──
|
||||
154
src/components/profile/PrivacyTab.tsx
Normal file
154
src/components/profile/PrivacyTab.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
export function PrivacyTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [settings, setSettings] = useState({
|
||||
show_email: false,
|
||||
show_wallets: false,
|
||||
show_xp_level: true,
|
||||
show_badges: true,
|
||||
allow_direct_messages: true,
|
||||
profile_visibility: 'public' as 'public' | 'unlisted' | 'private',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch('/api/v1/auth/privacy-settings')
|
||||
.then((res) => {
|
||||
if (res.settings) {
|
||||
setSettings(res.settings)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults if not set yet
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiFetch('/api/v1/auth/privacy-settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
setMessage({ type: 'success', text: 'Privacy settings updated successfully.' })
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.message || 'Failed to update settings.' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const Toggle = ({
|
||||
label,
|
||||
desc,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
desc: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) => (
|
||||
<div className="flex items-center justify-between bg-[#1a1a2e] rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-white font-medium">{label}</div>
|
||||
<div className="text-xs text-gray-500">{desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${checked ? 'bg-purple-600' : 'bg-gray-600'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${checked ? 'translate-x-5' : 'translate-x-0'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Control what information is visible to other users on the platform.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle
|
||||
label="Show Email Address"
|
||||
desc="Allow others to see your email on your public profile."
|
||||
checked={settings.show_email}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, show_email: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Connected Wallets"
|
||||
desc="Display your linked wallet addresses on your profile."
|
||||
checked={settings.show_wallets}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, show_wallets: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show XP & Level"
|
||||
desc="Display your experience points and user level."
|
||||
checked={settings.show_xp_level}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, show_xp_level: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Badges"
|
||||
desc="Display your earned achievements and badges."
|
||||
checked={settings.show_badges}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, show_badges: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Allow Direct Messages"
|
||||
desc="Receive direct messages from other platform users."
|
||||
checked={settings.allow_direct_messages}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, allow_direct_messages: v }))}
|
||||
/>
|
||||
|
||||
<div className="bg-[#1a1a2e] rounded-lg p-4">
|
||||
<div className="text-sm text-white font-medium mb-2">Profile Visibility</div>
|
||||
<div className="text-xs text-gray-500 mb-4">Control who can find and view your profile.</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['public', 'unlisted', 'private'] as const).map((v) => (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
onClick={() => setSettings((s) => ({ ...s, profile_visibility: v }))}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium capitalize transition-colors ${
|
||||
settings.profile_visibility === v
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-[#0e0e16] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full px-4 py-2.5 bg-purple-600 hover:bg-purple-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Privacy Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings Tab ──
|
||||
300
src/components/profile/ProfileTab.tsx
Normal file
300
src/components/profile/ProfileTab.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
export function ProfileTab({ profile, onUpdate }: { profile: Profile; onUpdate: () => void }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
display_name: profile.display_name || '',
|
||||
bio: profile.bio || '',
|
||||
avatar_url: profile.avatar_url || '',
|
||||
website: profile.social_links?.website || '',
|
||||
twitter: profile.social_links?.twitter || '',
|
||||
github: profile.social_links?.github || '',
|
||||
telegram: profile.social_links?.telegram || '',
|
||||
ens_name: profile.ens_name || '',
|
||||
lens_handle: profile.lens_handle || '',
|
||||
farcaster_username: profile.farcaster_username || '',
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => apiFetch('/api/v1/auth/profile', { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
setIsEditing(false)
|
||||
onUpdate()
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = () => updateMutation.mutate(form)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Avatar & Basic Info */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-2xl bg-gradient-to-br from-purple-500 via-indigo-500 to-cyan-500 flex items-center justify-center text-3xl font-bold text-white shadow-lg shadow-purple-500/20">
|
||||
{profile.avatar_url ? (
|
||||
<img src={profile.avatar_url} alt="" className="w-full h-full rounded-2xl object-cover" />
|
||||
) : (
|
||||
(profile.display_name || profile.email).slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -bottom-2 -right-2 w-8 h-8 bg-purple-600 rounded-lg flex items-center justify-center hover:bg-purple-500 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
placeholder="Display name"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
maxLength={50}
|
||||
/>
|
||||
<textarea
|
||||
value={form.bio}
|
||||
onChange={(e) => setForm({ ...form, bio: e.target.value })}
|
||||
placeholder="Bio (max 200 chars)"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2 text-white focus:border-purple-500 focus:outline-none resize-none"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={form.avatar_url}
|
||||
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
|
||||
placeholder="Avatar URL"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="px-4 py-2 bg-[#1a1a2e] hover:bg-white/5 rounded-lg text-sm text-gray-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-white">{profile.display_name || profile.email}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{profile.email}</p>
|
||||
{profile.bio && <p className="text-gray-500 text-sm mt-2">{profile.bio}</p>}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium ${
|
||||
profile.tier === 'UNLIMITED'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: profile.tier === 'PRO'
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: profile.tier === 'STARTER'
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{profile.tier}
|
||||
</span>
|
||||
<span className="px-2.5 py-1 rounded-lg text-xs font-medium bg-gray-500/20 text-gray-400">
|
||||
Lvl {profile.level}
|
||||
</span>
|
||||
<span className="px-2.5 py-1 rounded-lg text-xs font-medium bg-yellow-500/20 text-yellow-400">
|
||||
{profile.xp} XP
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Web3 Identity</h3>
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="url"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
placeholder="Website"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Twitter className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.twitter}
|
||||
onChange={(e) => setForm({ ...form, twitter: e.target.value })}
|
||||
placeholder="Twitter / X"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Github className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.github}
|
||||
onChange={(e) => setForm({ ...form, github: e.target.value })}
|
||||
placeholder="GitHub"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<MessageCircle className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.telegram}
|
||||
onChange={(e) => setForm({ ...form, telegram: e.target.value })}
|
||||
placeholder="Telegram"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Link2 className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.ens_name}
|
||||
onChange={(e) => setForm({ ...form, ens_name: e.target.value })}
|
||||
placeholder="ENS Name"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Link2 className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={form.lens_handle}
|
||||
onChange={(e) => setForm({ ...form, lens_handle: e.target.value })}
|
||||
placeholder="Lens Handle"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{profile.ens_name && (
|
||||
<a
|
||||
href={`https://app.ens.domains/${profile.ens_name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Link2 className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-gray-300">{profile.ens_name}</span>
|
||||
</a>
|
||||
)}
|
||||
{profile.lens_handle && (
|
||||
<a
|
||||
href={`https://hey.xyz/u/${profile.lens_handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Link2 className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm text-gray-300">{profile.lens_handle}</span>
|
||||
</a>
|
||||
)}
|
||||
{profile.farcaster_username && (
|
||||
<a
|
||||
href={`https://warpcast.com/${profile.farcaster_username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm text-gray-300">@{profile.farcaster_username}</span>
|
||||
</a>
|
||||
)}
|
||||
{profile.social_links?.twitter && (
|
||||
<a
|
||||
href={`https://x.com/${profile.social_links.twitter}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Twitter className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-gray-300">@{profile.social_links.twitter}</span>
|
||||
</a>
|
||||
)}
|
||||
{profile.social_links?.github && (
|
||||
<a
|
||||
href={`https://github.com/${profile.social_links.github}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Github className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-300">{profile.social_links.github}</span>
|
||||
</a>
|
||||
)}
|
||||
{profile.social_links?.website && (
|
||||
<a
|
||||
href={profile.social_links.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a2e] rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Globe className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm text-gray-300 truncate">{new URL(profile.social_links.website).hostname}</span>
|
||||
</a>
|
||||
)}
|
||||
{!profile.ens_name &&
|
||||
!profile.lens_handle &&
|
||||
!profile.farcaster_username &&
|
||||
!profile.social_links?.twitter &&
|
||||
!profile.social_links?.github &&
|
||||
!profile.social_links?.website && (
|
||||
<p className="text-gray-500 text-sm col-span-full">
|
||||
No web3 identities linked. Click edit to add your ENS, Lens, Farcaster, or social accounts.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'XP', value: profile.xp, color: 'text-yellow-400' },
|
||||
{ label: 'Level', value: profile.level, color: 'text-purple-400' },
|
||||
{ label: 'Wallets', value: profile.wallets?.length || 0, color: 'text-cyan-400' },
|
||||
{ label: 'Badges', value: profile.badges?.length || 0, color: 'text-green-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-[#0e0e16] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Wallets Tab ──
|
||||
129
src/components/profile/SecurityTab.tsx
Normal file
129
src/components/profile/SecurityTab.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
export function SecurityTab() {
|
||||
const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' })
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const changeMutation = useMutation({
|
||||
mutationFn: (data: { current_password: string; new_password: string }) =>
|
||||
apiFetch('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
setSuccess('Password changed successfully')
|
||||
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||
setError('')
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message)
|
||||
setSuccess('')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (passwordForm.new !== passwordForm.confirm) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
if (passwordForm.new.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
changeMutation.mutate({ current_password: passwordForm.current, new_password: passwordForm.new })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<Key className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Change Password</h3>
|
||||
<p className="text-sm text-gray-500">Update your account password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordForm.current}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, current: e.target.value })}
|
||||
placeholder="Current password"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2.5 text-white focus:border-purple-500 focus:outline-none pr-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordForm.new}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, new: e.target.value })}
|
||||
placeholder="New password (min 8 chars)"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2.5 text-white focus:border-purple-500 focus:outline-none pr-10"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordForm.confirm}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
|
||||
placeholder="Confirm new password"
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2.5 text-white focus:border-purple-500 focus:outline-none pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{success && <p className="text-green-400 text-sm">{success}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changeMutation.isPending}
|
||||
className="w-full py-2.5 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{changeMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Two-Factor Authentication</h3>
|
||||
<p className="text-sm text-gray-500">Add an extra layer of security</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-[#1a1a2e] rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-sm text-gray-400">2FA is not enabled</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-purple-600/50 text-purple-300 rounded-lg text-sm cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Notifications Tab ──
|
||||
125
src/components/profile/SettingsTab.tsx
Normal file
125
src/components/profile/SettingsTab.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
export function SettingsTab() {
|
||||
const { logout } = useAuth()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!deletePassword) {
|
||||
setDeleteError('Please enter your password to confirm.')
|
||||
return
|
||||
}
|
||||
setIsDeleting(true)
|
||||
setDeleteError('')
|
||||
try {
|
||||
await apiFetch('/api/v1/auth/delete-account', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: deletePassword, confirm_delete: true }),
|
||||
})
|
||||
// Clear local storage and redirect
|
||||
localStorage.removeItem('rmi_token')
|
||||
localStorage.removeItem('rmi_user')
|
||||
window.location.href = '/'
|
||||
} catch (err: any) {
|
||||
setDeleteError(err.message || 'Failed to delete account. Please check your password.')
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Account Settings</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between bg-[#1a1a2e] rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-white">Export Data</div>
|
||||
<div className="text-xs text-gray-500">Download all your data (GDPR)</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-purple-600/20 text-purple-400 rounded-lg text-sm hover:bg-purple-600/30 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-[#1a1a2e] rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-white">Sign Out All Devices</div>
|
||||
<div className="text-xs text-gray-500">Invalidate all active sessions</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Sign out from all devices? You will need to log in again.')) {
|
||||
logout()
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg text-sm hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Sign Out All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-400 text-sm hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete Account...
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm mb-3 font-medium">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
This will permanently delete your account and all data. This action cannot be undone.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Enter your password to confirm:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0e0e16] border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-red-500/50"
|
||||
placeholder="Your password"
|
||||
/>
|
||||
</div>
|
||||
{deleteError && <p className="text-red-400 text-xs">{deleteError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeletePassword('')
|
||||
setDeleteError('')
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-[#1a1a2e] text-gray-400 rounded-lg text-sm hover:bg-[#252540] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-500 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Permanently Delete Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Profile Page ──
|
||||
275
src/components/profile/WalletsTab.tsx
Normal file
275
src/components/profile/WalletsTab.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
export function WalletsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
|
||||
const [selectedChain, setSelectedChain] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectError, setConnectError] = useState('')
|
||||
|
||||
const { data: chains } = useQuery<Chain[]>({
|
||||
queryKey: ['wallet-chains'],
|
||||
queryFn: () => apiFetch('/api/v1/auth/wallet/chains').then((r) => r.chains),
|
||||
})
|
||||
|
||||
const { data: linkedWallets } = useQuery<LinkedWallet[]>({
|
||||
queryKey: ['linked-wallets'],
|
||||
queryFn: () => apiFetch('/api/v1/auth/wallet/list').then((r) => r.wallets),
|
||||
})
|
||||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: (data: { address: string; chain: string; signature: string; message: string }) =>
|
||||
apiFetch('/api/v1/auth/wallet/link', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-wallets'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
setSelectedProvider(null)
|
||||
setConnectError('')
|
||||
},
|
||||
onError: (err: Error) => setConnectError(err.message),
|
||||
})
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: (data: { address: string; chain: string }) =>
|
||||
apiFetch('/api/v1/auth/wallet/unlink', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-wallets'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
},
|
||||
})
|
||||
|
||||
const setPrimaryMutation = useMutation({
|
||||
mutationFn: (data: { address: string; chain: string }) =>
|
||||
apiFetch('/api/v1/auth/wallet/set-primary', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['linked-wallets'] }),
|
||||
})
|
||||
|
||||
const handleConnect = async (providerId: string) => {
|
||||
setSelectedProvider(providerId)
|
||||
setConnectError('')
|
||||
setIsConnecting(true)
|
||||
|
||||
try {
|
||||
const message = `RugMunch Intelligence - Link wallet\nTimestamp: ${Date.now()}\nNonce: ${Math.random().toString(36).slice(2)}`
|
||||
let address = ''
|
||||
let signature = ''
|
||||
let chain = selectedChain
|
||||
|
||||
if (providerId === 'metamask' || providerId === 'coinbase') {
|
||||
const ethereum = (window as any).ethereum
|
||||
if (!ethereum) throw new Error('Wallet not installed')
|
||||
const accounts = await ethereum.request({ method: 'eth_requestAccounts' })
|
||||
address = accounts[0]
|
||||
const chainId = await ethereum.request({ method: 'eth_chainId' })
|
||||
const chainMap: Record<string, string> = {
|
||||
'0x1': 'ethereum',
|
||||
'0x89': 'polygon',
|
||||
'0xa4b1': 'arbitrum',
|
||||
'0xa': 'optimism',
|
||||
'0x2105': 'base',
|
||||
'0x38': 'bsc',
|
||||
}
|
||||
chain = chain || chainMap[chainId] || 'ethereum'
|
||||
signature = await ethereum.request({ method: 'personal_sign', params: [message, address] })
|
||||
} else if (providerId === 'phantom') {
|
||||
if (chain === 'solana' || !chain) {
|
||||
const solana = (window as any).solana
|
||||
if (!solana) throw new Error('Phantom not installed')
|
||||
const resp = await solana.connect()
|
||||
address = resp.publicKey.toString()
|
||||
chain = 'solana'
|
||||
const encoded = new TextEncoder().encode(message)
|
||||
const sig = await solana.signMessage(encoded, 'utf8')
|
||||
signature = btoa(String.fromCharCode(...new Uint8Array(sig.signature)))
|
||||
} else {
|
||||
const eth = (window as any).phantom?.ethereum
|
||||
if (!eth) throw new Error('Phantom EVM not available')
|
||||
const accounts = await eth.request({ method: 'eth_requestAccounts' })
|
||||
address = accounts[0]
|
||||
signature = await eth.request({ method: 'personal_sign', params: [message, address] })
|
||||
}
|
||||
} else if (providerId === 'tronlink') {
|
||||
const tronWeb = (window as any).tronWeb
|
||||
if (!tronWeb) throw new Error('TronLink not installed')
|
||||
address = tronWeb.defaultAddress.base58
|
||||
chain = 'tron'
|
||||
signature = await tronWeb.trx.sign(message)
|
||||
} else {
|
||||
throw new Error(`Connection for ${providerId} not yet implemented`)
|
||||
}
|
||||
|
||||
await linkMutation.mutateAsync({ address, chain, signature, message })
|
||||
} catch (err: any) {
|
||||
setConnectError(err.message || 'Connection failed')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyAddress = (addr: string) => {
|
||||
navigator.clipboard.writeText(addr)
|
||||
}
|
||||
|
||||
const WALLET_PROVIDERS = [
|
||||
{ id: 'phantom', name: 'Phantom', icon: '👻', chains: ['solana', 'ethereum', 'polygon'] },
|
||||
{
|
||||
id: 'metamask',
|
||||
name: 'MetaMask',
|
||||
icon: '🦊',
|
||||
chains: ['ethereum', 'base', 'polygon', 'bsc', 'avalanche', 'arbitrum', 'optimism'],
|
||||
},
|
||||
{ id: 'coinbase', name: 'Coinbase', icon: '🔵', chains: ['ethereum', 'base', 'polygon', 'optimism', 'arbitrum'] },
|
||||
{ id: 'trust', name: 'Trust Wallet', icon: '🔐', chains: ['ethereum', 'bsc', 'solana', 'tron'] },
|
||||
{ id: 'tronlink', name: 'TronLink', icon: '⚡', chains: ['tron'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Linked Wallets */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Linked Wallets</h3>
|
||||
{linkedWallets?.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No wallets linked yet.</p>
|
||||
<p className="text-gray-600 text-xs mt-1">Connect a wallet below to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{linkedWallets?.map((wallet) => (
|
||||
<div
|
||||
key={`${wallet.chain}-${wallet.address}`}
|
||||
className="flex items-center justify-between bg-[#1a1a2e] rounded-xl p-4 border border-white/5 hover:border-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center text-lg ${CHAIN_COLORS[wallet.chain] || 'bg-gray-500/20 text-gray-400'}`}
|
||||
>
|
||||
{CHAIN_ICONS[wallet.chain] || '💎'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-sm font-mono">
|
||||
{wallet.address.slice(0, 8)}...{wallet.address.slice(-6)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyAddress(wallet.address)}
|
||||
className="p-1 rounded hover:bg-white/5 transition-colors"
|
||||
title="Copy address"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 text-gray-500" />
|
||||
</button>
|
||||
<a
|
||||
href={
|
||||
wallet.chain === 'solana'
|
||||
? `https://solscan.io/account/${wallet.address}`
|
||||
: wallet.chain === 'tron'
|
||||
? `https://tronscan.org/#/address/${wallet.address}`
|
||||
: `https://etherscan.io/address/${wallet.address}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 rounded hover:bg-white/5 transition-colors"
|
||||
title="View on explorer"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-gray-500" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${CHAIN_COLORS[wallet.chain] || 'bg-gray-500/20 text-gray-400'}`}
|
||||
>
|
||||
{wallet.chain}
|
||||
</span>
|
||||
{wallet.is_primary && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-yellow-400">
|
||||
<Star className="w-3 h-3" /> Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!wallet.is_primary && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrimaryMutation.mutate({ address: wallet.address, chain: wallet.chain })}
|
||||
className="px-3 py-1.5 text-xs bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-lg transition-colors"
|
||||
>
|
||||
Set Primary
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to unlink this wallet?')) {
|
||||
unlinkMutation.mutate({ address: wallet.address, chain: wallet.chain })
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-red-500/10 transition-colors"
|
||||
title="Unlink wallet"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connect New Wallet */}
|
||||
<div className="bg-[#0e0e16] border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Connect New Wallet</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-400 mb-2 block">Select Chain (optional)</label>
|
||||
<select
|
||||
value={selectedChain}
|
||||
onChange={(e) => setSelectedChain(e.target.value)}
|
||||
className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg px-3 py-2 text-white focus:border-purple-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Auto-detect from wallet</option>
|
||||
{chains?.map((chain) => (
|
||||
<option key={chain.id} value={chain.id}>
|
||||
{chain.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{WALLET_PROVIDERS.map((provider) => (
|
||||
<button
|
||||
type="button"
|
||||
key={provider.id}
|
||||
onClick={() => handleConnect(provider.id)}
|
||||
disabled={isConnecting}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border transition-all ${
|
||||
selectedProvider === provider.id
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/10 bg-[#1a1a2e] hover:border-white/20 hover:bg-white/5'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<span className="text-2xl">{provider.icon}</span>
|
||||
<span className="text-xs text-gray-300 font-medium text-center">{provider.name}</span>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{provider.chains.slice(0, 3).join(', ')}
|
||||
{provider.chains.length > 3 && '...'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{connectError && (
|
||||
<div className="mt-4 flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
<p className="text-red-400 text-sm">{connectError}</p>
|
||||
</div>
|
||||
)}
|
||||
{isConnecting && <p className="mt-4 text-purple-400 text-sm">Connecting... Check your wallet extension.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Security Tab ──
|
||||
155
src/components/rugmaps/RugMapsCanvas.tsx
Normal file
155
src/components/rugmaps/RugMapsCanvas.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import cytoscape, { Core, EventObject } from 'cytoscape'
|
||||
import fcose from 'cytoscape-fcose'
|
||||
import { Download, Maximize2, Minimize2, RotateCcw, Search, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
cytoscape.use(fcose)
|
||||
|
||||
interface RugMapsCanvasProps {
|
||||
data: {
|
||||
nodes: { id: string; label: string; color: string; radius: number; type: string; risk_score: number }[]
|
||||
edges: { source: string; target: string; value: number; strength: number }[]
|
||||
} | null
|
||||
chain: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function RugMapsCanvas({ data, chain, width = 800, height = 500 }: RugMapsCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cyRef = useRef<Core | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<any>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data?.nodes?.length) return
|
||||
|
||||
if (cyRef.current) cyRef.current.destroy()
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: [
|
||||
...data.nodes.map((n) => ({
|
||||
data: { id: n.id, label: n.label, color: n.color, radius: n.radius, type: n.type, risk_score: n.risk_score },
|
||||
})),
|
||||
...data.edges.map((e, i) => ({
|
||||
data: { id: `e${i}`, source: e.source, target: e.target, weight: e.strength * 5, value: e.value },
|
||||
})),
|
||||
],
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': 'data(color)',
|
||||
'width': 'mapData(radius, 0, 50, 8, 40)',
|
||||
'height': 'mapData(radius, 0, 50, 8, 40)',
|
||||
'label': 'data(label)',
|
||||
'font-size': '10px',
|
||||
'color': '#e0e0e0',
|
||||
'text-valign': 'bottom',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': 4,
|
||||
'border-width': 2,
|
||||
'border-color': '#1a1a2e',
|
||||
'transition-property': 'background-color, width, height',
|
||||
'transition-duration': 300,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 'mapData(weight, 0, 10, 0.5, 4)',
|
||||
'line-color': '#2a2a4e',
|
||||
'target-arrow-color': '#2a2a4e',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'arrow-scale': 0.5,
|
||||
'curve-style': 'bezier',
|
||||
'opacity': 0.6,
|
||||
},
|
||||
},
|
||||
{ selector: 'node:selected', style: { 'border-color': '#00ff88', 'border-width': 3 } },
|
||||
{ selector: '.highlighted', style: { 'border-color': '#ffd43b', 'border-width': 3, 'z-index': 999 } },
|
||||
],
|
||||
layout: {
|
||||
name: 'fcose',
|
||||
animate: true,
|
||||
animationDuration: 1500,
|
||||
animationEasing: 'ease-in-out',
|
||||
fit: true,
|
||||
padding: 40,
|
||||
nodeRepulsion: () => 8000,
|
||||
idealEdgeLength: () => 120,
|
||||
gravity: 0.25,
|
||||
numIter: 2000,
|
||||
tile: true,
|
||||
} as any,
|
||||
})
|
||||
|
||||
cy.on('tap', 'node', (evt: EventObject) => {
|
||||
const node = evt.target
|
||||
setSelectedNode(node.data())
|
||||
})
|
||||
cy.on('tap', (evt: EventObject) => {
|
||||
if (evt.target === cy) setSelectedNode(null)
|
||||
})
|
||||
|
||||
cyRef.current = cy
|
||||
return () => { cy.destroy() }
|
||||
}, [data])
|
||||
|
||||
const zoomIn = useCallback(() => cyRef.current?.zoom(cyRef.current.zoom() * 1.3), [])
|
||||
const zoomOut = useCallback(() => cyRef.current?.zoom(cyRef.current.zoom() / 1.3), [])
|
||||
const fitAll = useCallback(() => cyRef.current?.fit(undefined, 40), [])
|
||||
const toggleFullscreen = useCallback(() => setIsFullscreen((f) => !f), [])
|
||||
|
||||
const exportPng = useCallback(() => {
|
||||
if (!cyRef.current) return
|
||||
const png = cyRef.current.png({ full: true, bg: '#0a0a1a', scale: 2 })
|
||||
const a = document.createElement('a')
|
||||
a.href = png
|
||||
a.download = `rugmap-${Date.now()}.png`
|
||||
a.click()
|
||||
}, [])
|
||||
|
||||
if (!data) {
|
||||
return <div className="flex items-center justify-center h-full text-gray-500">Enter a wallet address to generate a map</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-[#0a0a1a]' : ''}`}>
|
||||
<div className="absolute top-2 right-2 z-10 flex gap-1">
|
||||
<button onClick={zoomIn} className="p-1.5 rounded bg-[#1a1a2e] hover:bg-[#2a2a3e] text-gray-300"><ZoomIn size={16} /></button>
|
||||
<button onClick={zoomOut} className="p-1.5 rounded bg-[#1a1a2e] hover:bg-[#2a2a3e] text-gray-300"><ZoomOut size={16} /></button>
|
||||
<button onClick={fitAll} className="p-1.5 rounded bg-[#1a1a2e] hover:bg-[#2a2a3e] text-gray-300"><RotateCcw size={16} /></button>
|
||||
<button onClick={toggleFullscreen} className="p-1.5 rounded bg-[#1a1a2e] hover:bg-[#2a2a3e] text-gray-300">{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>
|
||||
<button onClick={exportPng} className="p-1.5 rounded bg-[#1a1a2e] hover:bg-[#2a2a3e] text-green-400"><Download size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} style={{ width: isFullscreen ? '100vw' : width, height: isFullscreen ? '100vh' : height }} className="rounded-xl overflow-hidden border border-white/10" />
|
||||
|
||||
{selectedNode && (
|
||||
<div className="absolute bottom-4 left-4 bg-[#1a1a2e] border border-white/10 rounded-lg p-3 max-w-xs z-10">
|
||||
<p className="text-xs text-gray-400 truncate">{selectedNode.id}</p>
|
||||
<p className="text-sm font-semibold">{selectedNode.label}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[#2a2a3e]">{selectedNode.type}</span>
|
||||
{selectedNode.risk_score > 0 && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${selectedNode.risk_score >= 70 ? 'bg-red-500/20 text-red-400' : selectedNode.risk_score >= 40 ? 'bg-yellow-500/20 text-yellow-400' : 'bg-green-500/20 text-green-400'}`}>
|
||||
Risk: {selectedNode.risk_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 right-4 flex gap-2 z-10">
|
||||
{['exchange_cex', 'dex', 'scammer', 'dev_wallet', 'whale', 'mixer', 'bridge'].map((type) => (
|
||||
<div key={type} className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: { exchange_cex: '#4dabf7', dex: '#74c0fc', scammer: '#ff0000', dev_wallet: '#da77f2', whale: '#ffd43b', mixer: '#868e96', bridge: '#20c997' }[type] || '#495057' }} />
|
||||
{type.replace('_', ' ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/components/rugmaps/WalletList.tsx
Normal file
40
src/components/rugmaps/WalletList.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { AlertTriangle, CheckCircle, Shield } from 'lucide-react'
|
||||
|
||||
interface WalletListProps {
|
||||
nodes: { id: string; label: string; type: string; risk_score: number; volume_in: number; volume_out: number; tx_count: number }[]
|
||||
onSelect: (id: string) => void
|
||||
selectedId?: string | null
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, any> = { exchange_cex: Shield, scammer: AlertTriangle, dev_wallet: Shield, whale: Shield }
|
||||
|
||||
export function WalletList({ nodes, onSelect, selectedId }: WalletListProps) {
|
||||
const sorted = [...nodes].sort((a, b) => b.volume_in + b.volume_out - (a.volume_in + a.volume_out))
|
||||
|
||||
return (
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
{sorted.slice(0, 50).map((node) => {
|
||||
const Icon = TYPE_ICONS[node.type] || AlertTriangle
|
||||
const riskColor = node.risk_score >= 70 ? 'text-red-400' : node.risk_score >= 40 ? 'text-yellow-400' : 'text-green-400'
|
||||
const totalVol = node.volume_in + node.volume_out
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors ${selectedId === node.id ? 'bg-[#2a2a4e] border border-[#4dabf7]/30' : 'hover:bg-[#1a1a2e] border border-transparent'}`}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: node.type === 'scammer' ? '#ff0000' : node.type === 'exchange_cex' ? '#4dabf7' : node.type === 'whale' ? '#ffd43b' : '#495057' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{node.label}</p>
|
||||
<p className="text-[10px] text-gray-500">{node.type.replace('_', ' ')} · {node.tx_count} txs</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className={`text-xs ${riskColor}`}>{node.risk_score > 0 ? `${Math.round(node.risk_score)}` : '--'}</p>
|
||||
<p className="text-[10px] text-gray-500">{totalVol > 0 ? `$${(totalVol / 1e6).toFixed(1)}M` : '--'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/components/token-detail/CandlestickChart.tsx
Normal file
89
src/components/token-detail/CandlestickChart.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export function CandlestickChart({ data, _timeframe }: { data: any[]; timeframe: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data.length) return
|
||||
const container = containerRef.current
|
||||
|
||||
const chart = createChart(container, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: 'transparent' },
|
||||
textColor: '#6b7280',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: 10,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(255,255,255,0.03)' },
|
||||
horzLines: { color: 'rgba(255,255,255,0.03)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(139,92,246,0.2)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
horzLine: { color: 'rgba(139,92,246,0.2)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(255,255,255,0.05)' },
|
||||
timeScale: { borderColor: 'rgba(255,255,255,0.05)', timeVisible: true },
|
||||
width: container.clientWidth,
|
||||
height: 400,
|
||||
handleScroll: { vertTouchDrag: false },
|
||||
})
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#00E676',
|
||||
downColor: '#FF3366',
|
||||
borderUpColor: '#00E676',
|
||||
borderDownColor: '#FF3366',
|
||||
wickUpColor: '#00E676',
|
||||
wickDownColor: '#FF3366',
|
||||
})
|
||||
|
||||
const volSeries = chart.addSeries(HistogramSeries, {
|
||||
color: '#8b5cf6',
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: '',
|
||||
})
|
||||
volSeries.priceScale().applyOptions({ scaleMargins: { top: 0.85, bottom: 0 } })
|
||||
|
||||
// Normalize candle data
|
||||
const candles = data.map((d: any) => ({
|
||||
time: d.time || d.t || d.timestamp,
|
||||
open: d.open ?? d.o ?? 0,
|
||||
high: d.high ?? d.h ?? 0,
|
||||
low: d.low ?? d.l ?? 0,
|
||||
close: d.close ?? d.c ?? 0,
|
||||
}))
|
||||
|
||||
candleSeries.setData(candles)
|
||||
volSeries.setData(
|
||||
candles.map((c: any, idx: number) => ({
|
||||
time: c.time,
|
||||
value: data[idx]?.volume ?? data[idx]?.v ?? 0,
|
||||
color: c.close >= c.open ? 'rgba(0,230,118,0.2)' : 'rgba(255,51,102,0.2)',
|
||||
})),
|
||||
)
|
||||
|
||||
chart.timeScale().fitContent()
|
||||
chartRef.current = chart
|
||||
|
||||
const handleResize = () => {
|
||||
if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart.remove()
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (!data.length)
|
||||
return (
|
||||
<div className="h-[400px] flex items-center justify-center text-gray-600 text-sm">No chart data available</div>
|
||||
)
|
||||
|
||||
return <div ref={containerRef} className="w-full h-[400px]" />
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// FallbackAreaChart — recharts fallback
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
49
src/components/token-detail/FallbackAreaChart.tsx
Normal file
49
src/components/token-detail/FallbackAreaChart.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export function FallbackAreaChart({ data }: { data: any[] }) {
|
||||
const chartData = data.map((d: any) => ({
|
||||
time: d.time || d.t || d.timestamp,
|
||||
close: d.close ?? d.c ?? 0,
|
||||
volume: d.volume ?? d.v ?? 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="priceGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tickFormatter={(v: any) => String(v).slice(-5)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
domain={['auto', 'auto']}
|
||||
tickFormatter={(v: number) => fmtUsd(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [name === 'close' ? fmtUsd(value) : fmtUsd(value), name]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="close"
|
||||
name="Price"
|
||||
stroke="#8b5cf6"
|
||||
fill="url(#priceGrad)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SignalRadar — reused from RugChartsPage pattern
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
37
src/components/token-detail/LaunchChart.tsx
Normal file
37
src/components/token-detail/LaunchChart.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export function LaunchChart({ data }: { data: any[] }) {
|
||||
if (!data?.length)
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" /> Launch Sequence (First 60s)
|
||||
</h3>
|
||||
<div className="h-[200px] flex items-center justify-center text-gray-600 text-sm">No launch data</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" /> Launch Sequence (First 60s)
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
label={{ value: 'Seconds', position: 'bottom', fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="buys" name="Buys/2s" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="cumulative" name="Cumulative" stroke="#8b5cf6" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RugProbability — bar chart from RugChartsPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
44
src/components/token-detail/RugProbability.tsx
Normal file
44
src/components/token-detail/RugProbability.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export function RugProbability({ prob }: { prob: any }) {
|
||||
if (!prob) return null
|
||||
|
||||
const barData = [
|
||||
{ period: 'Next 1h', risk: prob.next_1h_risk ?? 0 },
|
||||
{ period: 'Next 24h', risk: prob.next_24h_risk ?? 0 },
|
||||
{ period: 'Next 7d', risk: prob.next_7d_risk ?? 0 },
|
||||
{ period: 'Next 30d', risk: prob.next_30d_risk ?? 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" /> Rug Pull Probability
|
||||
</h3>
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-5xl font-black" style={{ color: riskColor(prob.overall_probability ?? 0) }}>
|
||||
{(prob.overall_probability ?? 0).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Overall Rug Probability</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis dataKey="period" tick={{ fill: '#6b7280', fontSize: 9 }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: number) => `${v.toFixed(0)}%`} />
|
||||
<Bar dataKey="risk" radius={[4, 4, 0, 0]}>
|
||||
{barData.map((_, i) => (
|
||||
<Cell key={i} fill={riskColor(barData[i].risk)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 text-[10px] text-gray-500 text-center">
|
||||
Confidence: {prob.confidence ?? 0}% · {prob.primary_risk_factors?.length || 0} risk factors
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// WalletClusterGraph — simplified from RugMapsPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
22
src/components/token-detail/SecurityBadge.tsx
Normal file
22
src/components/token-detail/SecurityBadge.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export function SecurityBadge({ label, ok }: { label: string; ok: boolean | null }) {
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-xl p-3 text-center">
|
||||
<div className="text-[10px] text-gray-400 mb-1">{label}</div>
|
||||
{ok == null ? (
|
||||
<div className="text-xs text-gray-600">Unknown</div>
|
||||
) : ok ? (
|
||||
<div className="flex items-center justify-center gap-1 text-emerald-400 text-xs font-bold">
|
||||
<ShieldCheck className="w-4 h-4" /> Safe
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-red-400 text-xs font-bold">
|
||||
<AlertTriangle className="w-4 h-4" /> Warning
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Main TokenDetailPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
36
src/components/token-detail/SignalRadar.tsx
Normal file
36
src/components/token-detail/SignalRadar.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export function SignalRadar({ signals }: { signals: Record<string, number> }) {
|
||||
const radarData = Object.entries(signals).map(([key, value]) => ({
|
||||
subject: key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
value,
|
||||
}))
|
||||
|
||||
if (!radarData.length)
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-cyan-400" /> Signal Breakdown
|
||||
</h3>
|
||||
<div className="h-[280px] flex items-center justify-center text-gray-600 text-sm">No signal data</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-cyan-400" /> Signal Breakdown
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#9ca3af', fontSize: 9 }} />
|
||||
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Radar name="Risk" dataKey="value" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LaunchChart — reused from RugChartsPage pattern
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
16
src/components/token-detail/StatCard.tsx
Normal file
16
src/components/token-detail/StatCard.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function StatCard({ label, value, icon, color }: { label: string; value: string; icon?: any; color?: string }) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-xl p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{Icon && <Icon className="w-3.5 h-3.5" style={{ color: color || '#6b7280' }} />}
|
||||
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SecurityBadge — for security checks
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
109
src/components/token-detail/WalletClusterGraph.tsx
Normal file
109
src/components/token-detail/WalletClusterGraph.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export function WalletClusterGraph({
|
||||
clusters,
|
||||
centerToken,
|
||||
}: {
|
||||
clusters: any[]
|
||||
centerToken: { name: string; symbol: string }
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
|
||||
if (!clusters.length)
|
||||
return (
|
||||
<div className="h-80 flex items-center justify-center text-gray-500 text-sm">
|
||||
No cluster data — supply appears decentralized
|
||||
</div>
|
||||
)
|
||||
|
||||
const cx = 400,
|
||||
cy = 300,
|
||||
maxR = 220
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 800 600" className="w-full h-auto">
|
||||
<defs>
|
||||
<filter id="glow-tc">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Center token */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={40}
|
||||
fill="rgba(139,92,246,0.2)"
|
||||
stroke="#8B5CF6"
|
||||
strokeWidth="3"
|
||||
filter="url(#glow-tc)"
|
||||
/>
|
||||
<text x={cx} y={cy - 6} textAnchor="middle" fill="#fff" fontSize="14" fontWeight="bold">
|
||||
{centerToken.symbol}
|
||||
</text>
|
||||
<text x={cx} y={cy + 12} textAnchor="middle" fill="#9ca3af" fontSize="9">
|
||||
{centerToken.name}
|
||||
</text>
|
||||
|
||||
{clusters.map((c, i) => {
|
||||
const angle = (i / clusters.length) * 2 * Math.PI - Math.PI / 2
|
||||
const r = maxR * 0.5 + (c.supply_percentage / 100) * maxR * 0.5
|
||||
const px = cx + Math.cos(angle) * r
|
||||
const py = cy + Math.sin(angle) * r
|
||||
const radius = Math.max(20, c.supply_percentage * 1.2)
|
||||
const color = riskColor(
|
||||
typeof c.risk === 'number'
|
||||
? c.risk
|
||||
: c.risk === 'HIGH' || c.risk === 'CRITICAL'
|
||||
? 75
|
||||
: c.risk === 'MEDIUM'
|
||||
? 50
|
||||
: 25,
|
||||
)
|
||||
const isHovered = hovered === c.id
|
||||
const isDimmed = hovered && !isHovered
|
||||
|
||||
return (
|
||||
<g key={c.id} opacity={isDimmed ? 0.2 : 1}>
|
||||
<line
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={px}
|
||||
y2={py}
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="6,4"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<circle
|
||||
cx={px}
|
||||
cy={py}
|
||||
r={isHovered ? radius + 5 : radius}
|
||||
fill={`${color}15`}
|
||||
stroke={color}
|
||||
strokeWidth={isHovered ? 3 : 2}
|
||||
className="cursor-pointer"
|
||||
filter={isHovered ? 'url(#glow-tc)' : undefined}
|
||||
onMouseEnter={() => setHovered(c.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
/>
|
||||
<text x={px} y={py - radius - 10} textAnchor="middle" fill={color} fontSize="11" fontWeight="bold">
|
||||
{c.name || `Cluster ${i + 1}`}
|
||||
</text>
|
||||
<text x={px} y={py} textAnchor="middle" fill="#fff" fontSize="13" fontWeight="bold">
|
||||
{c.supply_percentage}%
|
||||
</text>
|
||||
<text x={px} y={py + 14} textAnchor="middle" fill="#6b7280" fontSize="9">
|
||||
{c.wallet_count || 0} wallets
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// StatCard — reusable glass stat box
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
@ -12,7 +12,7 @@ const envSchema = z.object({
|
|||
VITE_SUPABASE_ANON_KEY: z.string().optional().default(''),
|
||||
|
||||
// API endpoint
|
||||
VITE_API_URL: z.string().optional().default('http://localhost:8000'),
|
||||
VITE_API_URL: z.string().optional().default(''),
|
||||
|
||||
// WebSocket endpoint
|
||||
VITE_WS_URL: z.string().optional().default('ws://localhost:8000/ws'),
|
||||
|
|
@ -21,9 +21,7 @@ const envSchema = z.object({
|
|||
VITE_GHOST_URL: z.string().optional().default(''),
|
||||
VITE_GHOST_KEY: z.string().optional().default(''),
|
||||
|
||||
// Helius RPC (Solana)
|
||||
VITE_HELIUS_RPC_URL: z.string().url().optional().default('https://mainnet.helius-rpc.com'),
|
||||
VITE_HELIUS_API_KEY: z.string().optional().default(''),
|
||||
// Helius RPC (Solana) — removed from client bundle; backend proxies all Helius calls
|
||||
})
|
||||
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
|
@ -51,7 +49,6 @@ export const SUPABASE_URL = env.VITE_SUPABASE_URL
|
|||
export const SUPABASE_ANON_KEY = env.VITE_SUPABASE_ANON_KEY
|
||||
export const GHOST_URL = env.VITE_GHOST_URL
|
||||
export const GHOST_KEY = env.VITE_GHOST_KEY
|
||||
export const HELIUS_RPC_URL = env.VITE_HELIUS_RPC_URL
|
||||
export const HELIUS_API_KEY = env.VITE_HELIUS_API_KEY
|
||||
// HELIUS_RPC_URL and HELIUS_API_KEY removed — backend proxies all Helius calls
|
||||
export const APP_NAME = 'RugMunch Intelligence'
|
||||
export const APP_VERSION = '2.0.0'
|
||||
|
|
|
|||
68
src/main.tsx
68
src/main.tsx
|
|
@ -33,27 +33,53 @@ window.onerror = (msg, url, line, col, error) => {
|
|||
col,
|
||||
time: Date.now(),
|
||||
})
|
||||
rootEl.innerHTML = `
|
||||
<div style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#07070b;color:#FF3366;font-family:monospace;padding:2rem;overflow:auto">
|
||||
<div style="max-width:700px;width:100%">
|
||||
<h3 style="color:#8B5CF6;margin-bottom:1rem">Runtime Error</h3>
|
||||
<div style="background:#0f0f14;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:1rem;margin-bottom:1rem">
|
||||
<p style="color:#FF3366;font-size:14px;word-break:break-word">${err.message || msg}</p>
|
||||
</div>
|
||||
${
|
||||
err.stack
|
||||
? `<div style="background:#0f0f14;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:1rem;margin-bottom:1rem">
|
||||
<p style="color:#8B5CF6;font-size:11px;margin-bottom:0.5rem">Stack Trace:</p>
|
||||
<pre style="color:#94A3B8;font-size:10px;overflow:auto;max-height:200px;white-space:pre-wrap">${err.stack.split('\\n').slice(0, 8).join('\\n')}</pre>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
<div style="color:#94A3B8;font-size:11px;margin-bottom:1rem">
|
||||
Source: ${url}:${line}:${col}
|
||||
</div>
|
||||
<button type="button" onclick="location.reload()" style="padding:10px 24px;background:#8B5CF6;border:none;color:white;border-radius:8px;cursor:pointer;font-size:14px">Reload Page</button>
|
||||
</div>
|
||||
</div>`
|
||||
rootEl.textContent = '' // cleared below, rebuilt safely
|
||||
const overlay = document.createElement('div')
|
||||
overlay.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#07070b;color:#FF3366;font-family:monospace;padding:2rem;overflow:auto'
|
||||
const inner = document.createElement('div')
|
||||
inner.style.cssText = 'max-width:700px;width:100%'
|
||||
|
||||
const h3 = document.createElement('h3')
|
||||
h3.style.cssText = 'color:#8B5CF6;margin-bottom:1rem'
|
||||
h3.textContent = 'Runtime Error'
|
||||
inner.appendChild(h3)
|
||||
|
||||
const errBox = document.createElement('div')
|
||||
errBox.style.cssText = 'background:#0f0f14;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:1rem;margin-bottom:1rem'
|
||||
const errP = document.createElement('p')
|
||||
errP.style.cssText = 'color:#FF3366;font-size:14px;word-break:break-word'
|
||||
errP.textContent = err.message || String(msg)
|
||||
errBox.appendChild(errP)
|
||||
inner.appendChild(errBox)
|
||||
|
||||
if (err.stack) {
|
||||
const stackBox = document.createElement('div')
|
||||
stackBox.style.cssText = 'background:#0f0f14;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:1rem;margin-bottom:1rem'
|
||||
const stackP = document.createElement('p')
|
||||
stackP.style.cssText = 'color:#8B5CF6;font-size:11px;margin-bottom:0.5rem'
|
||||
stackP.textContent = 'Stack Trace:'
|
||||
stackBox.appendChild(stackP)
|
||||
const pre = document.createElement('pre')
|
||||
pre.style.cssText = 'color:#94A3B8;font-size:10px;overflow:auto;max-height:200px;white-space:pre-wrap'
|
||||
pre.textContent = err.stack.split('\\n').slice(0, 8).join('\\n')
|
||||
stackBox.appendChild(pre)
|
||||
inner.appendChild(stackBox)
|
||||
}
|
||||
|
||||
const srcDiv = document.createElement('div')
|
||||
srcDiv.style.cssText = 'color:#94A3B8;font-size:11px;margin-bottom:1rem'
|
||||
srcDiv.textContent = `Source: ${url || '?'}:${line || '?'}:${col || '?'}`
|
||||
inner.appendChild(srcDiv)
|
||||
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.style.cssText = 'padding:10px 24px;background:#8B5CF6;border:none;color:white;border-radius:8px;cursor:pointer;font-size:14px'
|
||||
btn.textContent = 'Reload Page'
|
||||
btn.onclick = () => location.reload()
|
||||
inner.appendChild(btn)
|
||||
|
||||
overlay.appendChild(inner)
|
||||
rootEl.appendChild(overlay)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
2007
src/pages/MCPDocsPage.tsx
Executable file → Normal file
2007
src/pages/MCPDocsPage.tsx
Executable file → Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -158,460 +158,20 @@ function SectionHeader({
|
|||
}
|
||||
|
||||
// ── Token Row ────────────────────────────────────────────────────
|
||||
function TokenRow({
|
||||
token,
|
||||
index,
|
||||
onClick,
|
||||
showAiScore,
|
||||
}: {
|
||||
token: MoverToken
|
||||
index: number
|
||||
onClick: (t: MoverToken) => void
|
||||
showAiScore?: boolean
|
||||
}) {
|
||||
const isUp = token.change_24h >= 0
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.02, 0.3) }}
|
||||
onClick={() => onClick(token)}
|
||||
className="group grid grid-cols-12 gap-2 items-center px-4 py-3 rounded-xl border border-transparent hover:bg-white/[0.04] hover:border-white/[0.08] cursor-pointer transition-all"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3 min-w-0">
|
||||
<span className="text-xs text-gray-600 w-5 text-right shrink-0">#{token.rank}</span>
|
||||
<TokenIcon address={token.address} symbol={token.symbol} size={32} chain={token.chain} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm font-medium truncate">{token.name}</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{token.symbol} · {token.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.price)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className={`text-sm font-medium ${isUp ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isUp ? <ArrowUpRight className="w-3 h-3 inline mr-1" /> : <ArrowDownRight className="w-3 h-3 inline mr-1" />}
|
||||
{fmtPct(token.change_24h)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<div className="text-gray-300 text-sm">{fmtUsd(token.volume_24h)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right flex items-center justify-end gap-2">
|
||||
{showAiScore && token.ai_score != null && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-purple-500/20 text-purple-300">AI {token.ai_score}</span>
|
||||
)}
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: riskColor(token.risk_score) }} />
|
||||
<span className="text-xs text-gray-400">{token.risk_score}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Launch Card ─────────────────────────────────────────────────
|
||||
function LaunchCard({
|
||||
token,
|
||||
index,
|
||||
onClick,
|
||||
}: {
|
||||
token: LaunchToken
|
||||
index: number
|
||||
onClick: (t: LaunchToken) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
onClick={() => onClick(token)}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] hover:border-white/[0.12] cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-600/30 to-cyan-900/30 flex items-center justify-center">
|
||||
<Rocket className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{token.name}</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{token.symbol} · {token.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-cyan-500/20 text-cyan-300">{token.age_minutes}m old</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-2">
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.price)}</div>
|
||||
<div className="text-gray-500 text-xs">Price</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{fmtUsd(token.market_cap)}</div>
|
||||
<div className="text-gray-500 text-xs">MCap</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{token.holders}</div>
|
||||
<div className="text-gray-500 text-xs">Holders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: riskColor(token.risk_score) }} />
|
||||
<span className="text-xs text-gray-400">Risk {token.risk_score}</span>
|
||||
</div>
|
||||
{token.bundler_detected && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/20 text-orange-300 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Bundler
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
import { TokenRow } from '@/components/market-intel/TokenRow'
|
||||
import { LaunchCard } from '@/components/market-intel/LaunchCard'
|
||||
import { ScamCard } from '@/components/market-intel/ScamCard'
|
||||
import { WhaleCard } from '@/components/market-intel/WhaleCard'
|
||||
import { ChainCard } from '@/components/market-intel/ChainCard'
|
||||
import { AlphaCard } from '@/components/market-intel/AlphaCard'
|
||||
import { RugCard } from '@/components/market-intel/RugCard'
|
||||
import { RunnerCard } from '@/components/market-intel/RunnerCard'
|
||||
import { FlowCard } from '@/components/market-intel/FlowCard'
|
||||
import { BundlerCard } from '@/components/market-intel/BundlerCard'
|
||||
import { DexFlowCard } from '@/components/market-intel/DexFlowCard'
|
||||
import { PredictionCard } from '@/components/market-intel/PredictionCard'
|
||||
|
||||
// ── Scam Card ────────────────────────────────────────────────────
|
||||
function ScamCard({ alert, index }: { alert: ScamAlert; index: number }) {
|
||||
const severityColor =
|
||||
alert.severity === 'critical'
|
||||
? 'text-red-400 bg-red-500/20'
|
||||
: alert.severity === 'high'
|
||||
? 'text-orange-400 bg-orange-500/20'
|
||||
: 'text-yellow-400 bg-yellow-500/20'
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skull className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white text-sm font-medium">{alert.symbol}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${severityColor}`}>{alert.severity}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(alert.detected_at)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{alert.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{alert.indicators?.slice(0, 3).map((ind, _i) => (
|
||||
<span key={ind} className="text-xs px-2 py-0.5 rounded bg-white/[0.06] text-gray-400">
|
||||
{ind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>{alert.victims} victims</span>
|
||||
<span>{fmtUsd(alert.amount_stolen)} stolen</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded ${alert.status === 'active' ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}`}
|
||||
>
|
||||
{alert.status}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Whale Card ───────────────────────────────────────────────────
|
||||
function WhaleCard({ move, index }: { move: WhaleMovement; index: number }) {
|
||||
const isBuy = move.type === 'buy'
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center ${isBuy ? 'bg-green-500/20' : 'bg-red-500/20'}`}
|
||||
>
|
||||
<Wallet className={`w-4 h-4 ${isBuy ? 'text-green-400' : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">
|
||||
{move.wallet_label || `${move.from_wallet?.slice(0, 8)}...`}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{move.token_symbol} · {move.chain?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${isBuy ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isBuy ? '+' : '-'}
|
||||
{fmtUsd(move.value_usd)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">{fmtTimeAgo(move.timestamp)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chain Card ───────────────────────────────────────────────────
|
||||
function ChainCard({ chain }: { chain: ChainActivity }) {
|
||||
const statusColor =
|
||||
chain.status === 'hot' ? 'text-red-400' : chain.status === 'warm' ? 'text-orange-400' : 'text-cyan-400'
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">{chain.chain}</span>
|
||||
<span className={`text-xs font-medium ${statusColor}`}>{chain.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">TPS</span> <span className="text-white ml-1">{chain.tps}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Gas</span> <span className="text-white ml-1">{chain.gas_usd}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Txs 24h</span> <span className="text-white ml-1">{chain.txs_24h}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Active</span>{' '}
|
||||
<span className="text-white ml-1">{chain.active_addresses?.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-green-500 to-red-500"
|
||||
style={{ width: `${chain.ai_risk_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Risk {chain.ai_risk_score}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Alpha Card ───────────────────────────────────────────────────
|
||||
function AlphaCard({ signal, index }: { signal: AlphaSignal; index: number }) {
|
||||
const urgencyColor =
|
||||
signal.urgency === 'immediate'
|
||||
? 'text-red-400 bg-red-500/20'
|
||||
: signal.urgency === 'soon'
|
||||
? 'text-orange-400 bg-orange-500/20'
|
||||
: 'text-cyan-400 bg-cyan-500/20'
|
||||
const TypeIcon =
|
||||
signal.type === 'dev_movement'
|
||||
? Crosshair
|
||||
: signal.type === 'insider_buy'
|
||||
? Eye
|
||||
: signal.type === 'bundler_forming'
|
||||
? Zap
|
||||
: signal.type === 'smart_money_in'
|
||||
? TrendingUp
|
||||
: signal.type === 'narrative_shift'
|
||||
? Flame
|
||||
: Wallet
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white text-sm font-medium">{signal.token_symbol}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${urgencyColor}`}>{signal.urgency}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(signal.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{signal.description}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>Confidence {signal.confidence}%</span>
|
||||
{signal.wallet_label && <span>Via {signal.wallet_label}</span>}
|
||||
{signal.amount && <span>{fmtUsd(signal.amount)}</span>}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rug Card ─────────────────────────────────────────────────────
|
||||
function RugCard({ rug, index }: { rug: LikelyRug; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-red-500/20 bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skull className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white text-sm font-medium">{rug.symbol}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-500/20 text-red-400">{rug.confidence}% conf</span>
|
||||
</div>
|
||||
<span className="text-red-400 text-xs font-medium">{rug.time_to_impact}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{rug.reasons?.[0]}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rug.indicators?.slice(0, 3).map((ind, _i) => (
|
||||
<span key={ind} className="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-300">
|
||||
{ind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Runner Card ──────────────────────────────────────────────────
|
||||
function RunnerCard({ runner, index }: { runner: PotentialRunner; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-green-500/20 bg-green-500/[0.03] hover:bg-green-500/[0.06] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="w-4 h-4 text-green-400" />
|
||||
<span className="text-white text-sm font-medium">{runner.symbol}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">AI {runner.ai_prediction}%</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{fmtTimeAgo(runner.detected_at)}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm mb-2">{runner.narrative}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-gray-500">
|
||||
Smart Money <span className="text-green-400">{fmtUsd(runner.smart_money_inflows)}</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Holders <span className="text-green-400">+{runner.holder_growth_rate}%</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Volume <span className="text-green-400">+{runner.volume_surge}x</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Social <span className="text-green-400">+{runner.social_mentions_delta}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Flow Card ────────────────────────────────────────────────────
|
||||
function FlowCard({ flow }: { flow: SmartMoneyFlow }) {
|
||||
const isInflow = flow.net_flow > 0
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">{flow.sector}</span>
|
||||
<span className={`text-sm font-medium ${isInflow ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isInflow ? '+' : ''}
|
||||
{fmtUsd(flow.net_flow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 mb-2">
|
||||
{flow.top_tokens?.slice(0, 3).map((t, _i) => (
|
||||
<div key={t.symbol} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">{t.symbol}</span>
|
||||
<span className="text-white">{fmtUsd(t.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="text-green-400">In {fmtUsd(flow.inflow_24h)}</span>
|
||||
<span className="text-red-400">Out {fmtUsd(flow.outflow_24h)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bundler Card ─────────────────────────────────────────────────
|
||||
function BundlerCard({ b }: { b: BundlerActivity }) {
|
||||
const statusColor =
|
||||
b.status === 'forming' ? 'text-yellow-400' : b.status === 'active' ? 'text-green-400' : 'text-red-400'
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">{b.symbol}</span>
|
||||
<span className={`text-xs font-medium ${statusColor}`}>{b.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm mb-2">
|
||||
<div>
|
||||
<div className="text-white font-medium">{b.wallet_count}</div>
|
||||
<div className="text-gray-500 text-xs">Wallets</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{b.coordinated_buy_count}</div>
|
||||
<div className="text-gray-500 text-xs">Buys</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{fmtUsd(b.total_volume)}</div>
|
||||
<div className="text-gray-500 text-xs">Volume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Pattern: <span className="text-purple-400">{b.pattern}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DEX Flow Card ────────────────────────────────────────────────
|
||||
function DexFlowCard({ flow }: { flow: DexFlow }) {
|
||||
const netPositive = flow.net_flow_1h > 0
|
||||
const buyPct = Math.min((flow.buy_pressure_1h / (flow.buy_pressure_1h + flow.sell_pressure_1h)) * 100, 100) || 50
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">{flow.symbol}</span>
|
||||
<span className={`text-xs ${netPositive ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{netPositive ? '+' : ''}
|
||||
{fmtUsd(flow.net_flow_1h)} 1h
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-green-500" style={{ width: `${buyPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{Math.round(buyPct)}% buy</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
LP +{fmtUsd(flow.lp_additions_24h)} / -{fmtUsd(flow.lp_removals_24h)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prediction Market Card ───────────────────────────────────────
|
||||
function PredictionCard({ market, index }: { market: PredictionMarket; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.05, 0.3) }}
|
||||
className="p-4 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-white text-sm font-medium line-clamp-1">{market.title}</span>
|
||||
</div>
|
||||
{market.trending && <span className="text-xs px-2 py-0.5 rounded bg-blue-500/20 text-blue-300">Trending</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm mb-2">
|
||||
<div>
|
||||
<div className="text-green-400 font-medium">{(market.yes_price * 100).toFixed(0)}%</div>
|
||||
<div className="text-gray-500 text-xs">Yes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-red-400 font-medium">{(market.no_price * 100).toFixed(0)}%</div>
|
||||
<div className="text-gray-500 text-xs">No</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{fmtUsd(market.volume)}</div>
|
||||
<div className="text-gray-500 text-xs">Volume</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -110,408 +110,16 @@ const TABS: { id: TabId; label: string; icon: any }[] = [
|
|||
// ═══════════════════════════════════════════════════════════════
|
||||
// CandlestickChart — lightweight-charts v5 wrapper
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function CandlestickChart({ data, _timeframe }: { data: any[]; timeframe: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data.length) return
|
||||
const container = containerRef.current
|
||||
import { CandlestickChart } from '@/components/token-detail/CandlestickChart'
|
||||
import { FallbackAreaChart } from '@/components/token-detail/FallbackAreaChart'
|
||||
import { SignalRadar } from '@/components/token-detail/SignalRadar'
|
||||
import { LaunchChart } from '@/components/token-detail/LaunchChart'
|
||||
import { RugProbability } from '@/components/token-detail/RugProbability'
|
||||
import { WalletClusterGraph } from '@/components/token-detail/WalletClusterGraph'
|
||||
import { StatCard } from '@/components/token-detail/StatCard'
|
||||
import { SecurityBadge } from '@/components/token-detail/SecurityBadge'
|
||||
|
||||
const chart = createChart(container, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: 'transparent' },
|
||||
textColor: '#6b7280',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: 10,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(255,255,255,0.03)' },
|
||||
horzLines: { color: 'rgba(255,255,255,0.03)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(139,92,246,0.2)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
horzLine: { color: 'rgba(139,92,246,0.2)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(255,255,255,0.05)' },
|
||||
timeScale: { borderColor: 'rgba(255,255,255,0.05)', timeVisible: true },
|
||||
width: container.clientWidth,
|
||||
height: 400,
|
||||
handleScroll: { vertTouchDrag: false },
|
||||
})
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#00E676',
|
||||
downColor: '#FF3366',
|
||||
borderUpColor: '#00E676',
|
||||
borderDownColor: '#FF3366',
|
||||
wickUpColor: '#00E676',
|
||||
wickDownColor: '#FF3366',
|
||||
})
|
||||
|
||||
const volSeries = chart.addSeries(HistogramSeries, {
|
||||
color: '#8b5cf6',
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: '',
|
||||
})
|
||||
volSeries.priceScale().applyOptions({ scaleMargins: { top: 0.85, bottom: 0 } })
|
||||
|
||||
// Normalize candle data
|
||||
const candles = data.map((d: any) => ({
|
||||
time: d.time || d.t || d.timestamp,
|
||||
open: d.open ?? d.o ?? 0,
|
||||
high: d.high ?? d.h ?? 0,
|
||||
low: d.low ?? d.l ?? 0,
|
||||
close: d.close ?? d.c ?? 0,
|
||||
}))
|
||||
|
||||
candleSeries.setData(candles)
|
||||
volSeries.setData(
|
||||
candles.map((c: any, idx: number) => ({
|
||||
time: c.time,
|
||||
value: data[idx]?.volume ?? data[idx]?.v ?? 0,
|
||||
color: c.close >= c.open ? 'rgba(0,230,118,0.2)' : 'rgba(255,51,102,0.2)',
|
||||
})),
|
||||
)
|
||||
|
||||
chart.timeScale().fitContent()
|
||||
chartRef.current = chart
|
||||
|
||||
const handleResize = () => {
|
||||
if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart.remove()
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (!data.length)
|
||||
return (
|
||||
<div className="h-[400px] flex items-center justify-center text-gray-600 text-sm">No chart data available</div>
|
||||
)
|
||||
|
||||
return <div ref={containerRef} className="w-full h-[400px]" />
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// FallbackAreaChart — recharts fallback
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function FallbackAreaChart({ data }: { data: any[] }) {
|
||||
const chartData = data.map((d: any) => ({
|
||||
time: d.time || d.t || d.timestamp,
|
||||
close: d.close ?? d.c ?? 0,
|
||||
volume: d.volume ?? d.v ?? 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="priceGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tickFormatter={(v: any) => String(v).slice(-5)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
domain={['auto', 'auto']}
|
||||
tickFormatter={(v: number) => fmtUsd(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [name === 'close' ? fmtUsd(value) : fmtUsd(value), name]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="close"
|
||||
name="Price"
|
||||
stroke="#8b5cf6"
|
||||
fill="url(#priceGrad)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SignalRadar — reused from RugChartsPage pattern
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function SignalRadar({ signals }: { signals: Record<string, number> }) {
|
||||
const radarData = Object.entries(signals).map(([key, value]) => ({
|
||||
subject: key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
value,
|
||||
}))
|
||||
|
||||
if (!radarData.length)
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-cyan-400" /> Signal Breakdown
|
||||
</h3>
|
||||
<div className="h-[280px] flex items-center justify-center text-gray-600 text-sm">No signal data</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-cyan-400" /> Signal Breakdown
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#9ca3af', fontSize: 9 }} />
|
||||
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Radar name="Risk" dataKey="value" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LaunchChart — reused from RugChartsPage pattern
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function LaunchChart({ data }: { data: any[] }) {
|
||||
if (!data?.length)
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" /> Launch Sequence (First 60s)
|
||||
</h3>
|
||||
<div className="h-[200px] flex items-center justify-center text-gray-600 text-sm">No launch data</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" /> Launch Sequence (First 60s)
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
label={{ value: 'Seconds', position: 'bottom', fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="buys" name="Buys/2s" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="cumulative" name="Cumulative" stroke="#8b5cf6" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RugProbability — bar chart from RugChartsPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function RugProbability({ prob }: { prob: any }) {
|
||||
if (!prob) return null
|
||||
|
||||
const barData = [
|
||||
{ period: 'Next 1h', risk: prob.next_1h_risk ?? 0 },
|
||||
{ period: 'Next 24h', risk: prob.next_24h_risk ?? 0 },
|
||||
{ period: 'Next 7d', risk: prob.next_7d_risk ?? 0 },
|
||||
{ period: 'Next 30d', risk: prob.next_30d_risk ?? 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" /> Rug Pull Probability
|
||||
</h3>
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-5xl font-black" style={{ color: riskColor(prob.overall_probability ?? 0) }}>
|
||||
{(prob.overall_probability ?? 0).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Overall Rug Probability</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis dataKey="period" tick={{ fill: '#6b7280', fontSize: 9 }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: number) => `${v.toFixed(0)}%`} />
|
||||
<Bar dataKey="risk" radius={[4, 4, 0, 0]}>
|
||||
{barData.map((_, i) => (
|
||||
<Cell key={i} fill={riskColor(barData[i].risk)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 text-[10px] text-gray-500 text-center">
|
||||
Confidence: {prob.confidence ?? 0}% · {prob.primary_risk_factors?.length || 0} risk factors
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// WalletClusterGraph — simplified from RugMapsPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function WalletClusterGraph({
|
||||
clusters,
|
||||
centerToken,
|
||||
}: {
|
||||
clusters: any[]
|
||||
centerToken: { name: string; symbol: string }
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
|
||||
if (!clusters.length)
|
||||
return (
|
||||
<div className="h-80 flex items-center justify-center text-gray-500 text-sm">
|
||||
No cluster data — supply appears decentralized
|
||||
</div>
|
||||
)
|
||||
|
||||
const cx = 400,
|
||||
cy = 300,
|
||||
maxR = 220
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 800 600" className="w-full h-auto">
|
||||
<defs>
|
||||
<filter id="glow-tc">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Center token */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={40}
|
||||
fill="rgba(139,92,246,0.2)"
|
||||
stroke="#8B5CF6"
|
||||
strokeWidth="3"
|
||||
filter="url(#glow-tc)"
|
||||
/>
|
||||
<text x={cx} y={cy - 6} textAnchor="middle" fill="#fff" fontSize="14" fontWeight="bold">
|
||||
{centerToken.symbol}
|
||||
</text>
|
||||
<text x={cx} y={cy + 12} textAnchor="middle" fill="#9ca3af" fontSize="9">
|
||||
{centerToken.name}
|
||||
</text>
|
||||
|
||||
{clusters.map((c, i) => {
|
||||
const angle = (i / clusters.length) * 2 * Math.PI - Math.PI / 2
|
||||
const r = maxR * 0.5 + (c.supply_percentage / 100) * maxR * 0.5
|
||||
const px = cx + Math.cos(angle) * r
|
||||
const py = cy + Math.sin(angle) * r
|
||||
const radius = Math.max(20, c.supply_percentage * 1.2)
|
||||
const color = riskColor(
|
||||
typeof c.risk === 'number'
|
||||
? c.risk
|
||||
: c.risk === 'HIGH' || c.risk === 'CRITICAL'
|
||||
? 75
|
||||
: c.risk === 'MEDIUM'
|
||||
? 50
|
||||
: 25,
|
||||
)
|
||||
const isHovered = hovered === c.id
|
||||
const isDimmed = hovered && !isHovered
|
||||
|
||||
return (
|
||||
<g key={c.id} opacity={isDimmed ? 0.2 : 1}>
|
||||
<line
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={px}
|
||||
y2={py}
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="6,4"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<circle
|
||||
cx={px}
|
||||
cy={py}
|
||||
r={isHovered ? radius + 5 : radius}
|
||||
fill={`${color}15`}
|
||||
stroke={color}
|
||||
strokeWidth={isHovered ? 3 : 2}
|
||||
className="cursor-pointer"
|
||||
filter={isHovered ? 'url(#glow-tc)' : undefined}
|
||||
onMouseEnter={() => setHovered(c.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
/>
|
||||
<text x={px} y={py - radius - 10} textAnchor="middle" fill={color} fontSize="11" fontWeight="bold">
|
||||
{c.name || `Cluster ${i + 1}`}
|
||||
</text>
|
||||
<text x={px} y={py} textAnchor="middle" fill="#fff" fontSize="13" fontWeight="bold">
|
||||
{c.supply_percentage}%
|
||||
</text>
|
||||
<text x={px} y={py + 14} textAnchor="middle" fill="#6b7280" fontSize="9">
|
||||
{c.wallet_count || 0} wallets
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// StatCard — reusable glass stat box
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function StatCard({ label, value, icon, color }: { label: string; value: string; icon?: any; color?: string }) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-xl p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{Icon && <Icon className="w-3.5 h-3.5" style={{ color: color || '#6b7280' }} />}
|
||||
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SecurityBadge — for security checks
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function SecurityBadge({ label, ok }: { label: string; ok: boolean | null }) {
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-xl p-3 text-center">
|
||||
<div className="text-[10px] text-gray-400 mb-1">{label}</div>
|
||||
{ok == null ? (
|
||||
<div className="text-xs text-gray-600">Unknown</div>
|
||||
) : ok ? (
|
||||
<div className="flex items-center justify-center gap-1 text-emerald-400 text-xs font-bold">
|
||||
<ShieldCheck className="w-4 h-4" /> Safe
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-red-400 text-xs font-bold">
|
||||
<AlertTriangle className="w-4 h-4" /> Warning
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Main TokenDetailPage
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default function TokenDetailPage() {
|
||||
const { address } = useParams<{ address: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -19,7 +20,7 @@ import { useState } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { databus } from '@/services/databus'
|
||||
|
||||
const _BASE = import.meta.env.VITE_API_URL || ''
|
||||
const _BASE = API_BASE_URL || ''
|
||||
|
||||
interface WalletForensic {
|
||||
address: string
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
/**
|
||||
* 🤖 RMI AI Router — Frontend Client
|
||||
* All AI requests proxy through the backend (server-side keys, never exposed)
|
||||
|
|
@ -21,7 +22,7 @@ export interface RouterResult {
|
|||
latencyMs: number
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const API_BASE = API_BASE_URL || ''
|
||||
|
||||
class AIRouter {
|
||||
private modelCache: ModelConfig[] = []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
export interface DexPair {
|
||||
chainId: string
|
||||
dexId: string
|
||||
|
|
@ -51,7 +52,7 @@ export interface Holder {
|
|||
riskColor: string
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || ''
|
||||
const API_BASE = API_BASE_URL || ''
|
||||
|
||||
export async function searchPairs(query: string): Promise<DexPair[]> {
|
||||
const res = await fetch(`${API_BASE}/api/dex/search?q=${encodeURIComponent(query)}`)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
/**
|
||||
* RugMaps RAG Intelligence Bridge
|
||||
* Connects frontend to RMI's 3-pillar RAG engine (FAISS + SPLADE + entity exact)
|
||||
|
|
@ -43,7 +44,7 @@ export interface DeployerRisk {
|
|||
verdict: string
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rugmunch.io'
|
||||
const API_BASE = API_BASE_URL || ''
|
||||
|
||||
export async function searchRAG(
|
||||
query: string,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { API_BASE_URL } from '@/lib/env'
|
||||
/**
|
||||
* SENTINEL Scanner Bridge for RugMaps
|
||||
* Real-time risk scoring, honeypot detection, contract verification
|
||||
|
|
@ -26,7 +27,7 @@ export interface SentinelScanResult {
|
|||
verdict: string
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rugmunch.io'
|
||||
const API_BASE = API_BASE_URL || ''
|
||||
|
||||
export async function sentinelScan(tokenAddress: string, chain: string): Promise<SentinelScanResult | null> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/token/security-check`, {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,3 @@ vi.stubEnv('VITE_API_URL', 'http://localhost:8000')
|
|||
vi.stubEnv('VITE_WS_URL', 'ws://localhost:8000/ws')
|
||||
vi.stubEnv('VITE_GHOST_URL', '')
|
||||
vi.stubEnv('VITE_GHOST_KEY', '')
|
||||
vi.stubEnv('VITE_HELIUS_RPC_URL', 'https://mainnet.helius-rpc.com')
|
||||
vi.stubEnv('VITE_HELIUS_API_KEY', 'test-key')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue