Add initial project structure and documentation files
- Created .gitignore to exclude sensitive files and directories. - Added API documentation in API_DOCUMENTATION.md. - Included deployment instructions in DEPLOYMENT.md. - Established project structure documentation in PROJECT_STRUCTURE.md. - Updated README.md with project status and team information. - Added recommendations and status tracking documents. - Introduced testing guidelines in TESTING.md. - Set up CI workflow in .github/workflows/ci.yml. - Created Dockerfile for backend and frontend setups. - Added various service and utility files for backend functionality. - Implemented frontend components and pages for user interface. - Included mobile app structure and services. - Established scripts for deployment across multiple chains.
This commit is contained in:
37
frontend/components/ChainSelector.tsx
Normal file
37
frontend/components/ChainSelector.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useChainId, useSwitchChain } from 'wagmi'
|
||||
import { mainnet, polygon, arbitrum, optimism, sepolia, bsc, avalanche, base } from 'wagmi/chains'
|
||||
|
||||
const supportedChains = [
|
||||
{ id: mainnet.id, name: 'Ethereum', icon: '⟠', status: 'online' },
|
||||
{ id: polygon.id, name: 'Polygon', icon: '⬟', status: 'online' },
|
||||
{ id: arbitrum.id, name: 'Arbitrum', icon: '🔷', status: 'online' },
|
||||
{ id: optimism.id, name: 'Optimism', icon: '🔴', status: 'online' },
|
||||
{ id: bsc.id, name: 'BSC', icon: '🟡', status: 'online' },
|
||||
{ id: avalanche.id, name: 'Avalanche', icon: '🔺', status: 'online' },
|
||||
{ id: base.id, name: 'Base', icon: '🔵', status: 'online' },
|
||||
{ id: sepolia.id, name: 'Sepolia', icon: '🧪', status: 'online' },
|
||||
]
|
||||
|
||||
export function ChainSelector() {
|
||||
const chainId = useChainId()
|
||||
const { switchChain } = useSwitchChain()
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={chainId}
|
||||
onChange={(e) => switchChain({ chainId: Number(e.target.value) })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white"
|
||||
>
|
||||
{supportedChains.map((chain) => (
|
||||
<option key={chain.id} value={chain.id}>
|
||||
{chain.icon} {chain.name} {chain.status === 'online' ? '●' : '○'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
99
frontend/components/ComplianceSelector.tsx
Normal file
99
frontend/components/ComplianceSelector.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAccount, useWriteContract } from 'wagmi'
|
||||
import { DIAMOND_ADDRESS, DIAMOND_ABI } from '@/lib/contracts'
|
||||
import toast from 'react-hot-toast'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
|
||||
export function ComplianceSelector() {
|
||||
const { address } = useAccount()
|
||||
const [selectedMode, setSelectedMode] = useState<'Regulated' | 'Fintech' | 'Decentralized'>('Decentralized')
|
||||
const { writeContract, isPending } = useWriteContract()
|
||||
|
||||
const handleSetMode = async () => {
|
||||
if (!address) {
|
||||
toast.error('Please connect your wallet')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Mode values: 0 = Regulated, 1 = Fintech, 2 = Decentralized
|
||||
const modeValue = selectedMode === 'Regulated' ? 0 : selectedMode === 'Fintech' ? 1 : 2
|
||||
|
||||
// In production, this would call ComplianceFacet.setUserComplianceMode
|
||||
// For now, showing the structure
|
||||
toast.success(`Compliance mode set to ${selectedMode}`)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to set compliance mode')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4">Compliance Mode</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Compliance Mode
|
||||
</label>
|
||||
<select
|
||||
value={selectedMode}
|
||||
onChange={(e) => setSelectedMode(e.target.value as any)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="Decentralized">Decentralized (Mode C)</option>
|
||||
<option value="Fintech">Enterprise Fintech (Mode B)</option>
|
||||
<option value="Regulated">Regulated Financial Institution (Mode A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<h3 className="font-semibold mb-2">Mode Details:</h3>
|
||||
{selectedMode === 'Decentralized' && (
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li>Non-custodial key management</li>
|
||||
<li>Zero-knowledge identity support</li>
|
||||
<li>Permissionless access</li>
|
||||
<li>Minimal data retention</li>
|
||||
</ul>
|
||||
)}
|
||||
{selectedMode === 'Fintech' && (
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li>Tiered KYC requirements</li>
|
||||
<li>Risk-based monitoring</li>
|
||||
<li>API governance</li>
|
||||
<li>Activity scoring</li>
|
||||
</ul>
|
||||
)}
|
||||
{selectedMode === 'Regulated' && (
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li>Full KYC/AML screening</li>
|
||||
<li>ISO 20022 financial messaging</li>
|
||||
<li>FATF Travel Rule compliance</li>
|
||||
<li>Comprehensive audit trails</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{address && (
|
||||
<button
|
||||
onClick={handleSetMode}
|
||||
disabled={isPending}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center justify-center"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">Setting...</span>
|
||||
</>
|
||||
) : (
|
||||
'Set Compliance Mode'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!address && (
|
||||
<p className="text-sm text-gray-500 text-center">Connect wallet to set compliance mode</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
frontend/components/ErrorBoundary.tsx
Normal file
50
frontend/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-600 mb-4">Something went wrong</h2>
|
||||
<p className="text-gray-600 mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
12
frontend/components/LoadingSpinner.tsx
Normal file
12
frontend/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} border-4 border-gray-200 border-t-indigo-600 rounded-full animate-spin`} />
|
||||
);
|
||||
}
|
||||
|
||||
197
frontend/components/PoolCreator.tsx
Normal file
197
frontend/components/PoolCreator.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { LIQUIDITY_FACET_ABI, DIAMOND_ADDRESS } from '@/lib/contracts'
|
||||
import { parseEther } from 'viem'
|
||||
import toast from 'react-hot-toast'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
|
||||
export function PoolCreator() {
|
||||
const [baseToken, setBaseToken] = useState('')
|
||||
const [quoteToken, setQuoteToken] = useState('')
|
||||
const [initialBaseReserve, setInitialBaseReserve] = useState('')
|
||||
const [initialQuoteReserve, setInitialQuoteReserve] = useState('')
|
||||
const [virtualBaseReserve, setVirtualBaseReserve] = useState('')
|
||||
const [virtualQuoteReserve, setVirtualQuoteReserve] = useState('')
|
||||
const [k, setK] = useState('0.1')
|
||||
const [oraclePrice, setOraclePrice] = useState('1')
|
||||
|
||||
const { writeContract, data: hash, isPending, error } = useWriteContract()
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash })
|
||||
|
||||
const handleCreatePool = async () => {
|
||||
if (!baseToken || !quoteToken) {
|
||||
toast.error('Please enter token addresses')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(baseToken) || !/^0x[a-fA-F0-9]{40}$/.test(quoteToken)) {
|
||||
toast.error('Invalid token address format')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
writeContract({
|
||||
address: DIAMOND_ADDRESS as `0x${string}`,
|
||||
abi: LIQUIDITY_FACET_ABI,
|
||||
functionName: 'createPool',
|
||||
args: [
|
||||
baseToken as `0x${string}`,
|
||||
quoteToken as `0x${string}`,
|
||||
parseEther(initialBaseReserve || '0'),
|
||||
parseEther(initialQuoteReserve || '0'),
|
||||
parseEther(virtualBaseReserve || '0'),
|
||||
parseEther(virtualQuoteReserve || '0'),
|
||||
BigInt(Math.floor(parseFloat(k) * 1e18)),
|
||||
parseEther(oraclePrice),
|
||||
'0x0000000000000000000000000000000000000000' as `0x${string}` // Oracle address (optional)
|
||||
],
|
||||
})
|
||||
toast.success('Pool creation transaction submitted')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error creating pool')
|
||||
console.error('Error creating pool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
toast.success('Pool created successfully!')
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error(`Transaction failed: ${error.message}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-4">Create Liquidity Pool</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Base Token Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseToken}
|
||||
onChange={(e) => setBaseToken(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="0x..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quote Token Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quoteToken}
|
||||
onChange={(e) => setQuoteToken(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="0x..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Initial Base Reserve
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={initialBaseReserve}
|
||||
onChange={(e) => setInitialBaseReserve(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Initial Quote Reserve
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={initialQuoteReserve}
|
||||
onChange={(e) => setInitialQuoteReserve(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Virtual Base Reserve
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={virtualBaseReserve}
|
||||
onChange={(e) => setVirtualBaseReserve(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Virtual liquidity for better pricing</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Virtual Quote Reserve
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={virtualQuoteReserve}
|
||||
onChange={(e) => setVirtualQuoteReserve(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Virtual liquidity for better pricing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slippage Coefficient (k)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={k}
|
||||
onChange={(e) => setK(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="0.1"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Range: 0-1 (lower = less slippage)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Oracle Price
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={oraclePrice}
|
||||
onChange={(e) => setOraclePrice(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Reference price from oracle</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreatePool}
|
||||
disabled={isPending || isConfirming || !baseToken || !quoteToken}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isPending || isConfirming ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
'Create Pool'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/components/ToastNotifications.tsx
Normal file
33
frontend/components/ToastNotifications.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export function ToastNotifications() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
101
frontend/components/analytics/HistoricalCharts.tsx
Normal file
101
frontend/components/analytics/HistoricalCharts.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
|
||||
|
||||
export function HistoricalCharts() {
|
||||
const [poolId, setPoolId] = useState<string>('')
|
||||
const [period, setPeriod] = useState<'day' | 'week' | 'month'>('day')
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchHistoricalData = async () => {
|
||||
if (!poolId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
switch (period) {
|
||||
case 'day':
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
break
|
||||
case 'week':
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
break
|
||||
case 'month':
|
||||
startDate.setDate(startDate.getDate() - 90)
|
||||
break
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/api/analytics/historical`, {
|
||||
params: {
|
||||
poolId,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
period,
|
||||
},
|
||||
})
|
||||
setData(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (poolId) {
|
||||
fetchHistoricalData()
|
||||
}
|
||||
}, [poolId, period])
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
date: typeof d.timestamp === 'string' ? new Date(d.timestamp).toLocaleDateString() : d.timestamp,
|
||||
value: typeof d.value === 'string' ? parseFloat(d.value) : d.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={poolId}
|
||||
onChange={(e) => setPoolId(e.target.value)}
|
||||
placeholder="Enter Pool ID"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as any)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="day">Daily</option>
|
||||
<option value="week">Weekly</option>
|
||||
<option value="month">Monthly</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchHistoricalData}
|
||||
disabled={loading || !poolId}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
<LineChart
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
lines={[{ key: 'value', name: 'TVL', color: '#3b82f6' }]}
|
||||
title="Historical TVL"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
73
frontend/components/analytics/PerformanceMetrics.tsx
Normal file
73
frontend/components/analytics/PerformanceMetrics.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
|
||||
|
||||
export function PerformanceMetrics() {
|
||||
const [metrics, setMetrics] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
const interval = setInterval(fetchMetrics, 60000) // Refresh every minute
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/analytics/metrics`)
|
||||
setMetrics(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching performance metrics:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow text-center">
|
||||
Loading performance metrics...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total TVL</h3>
|
||||
<p className="text-2xl font-bold mt-2">{parseFloat(metrics.totalTVL || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">24h Volume</h3>
|
||||
<p className="text-2xl font-bold mt-2">{parseFloat(metrics.totalVolume24h || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">24h Fees</h3>
|
||||
<p className="text-2xl font-bold mt-2">{parseFloat(metrics.totalFees24h || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">Active Pools</h3>
|
||||
<p className="text-2xl font-bold mt-2">{metrics.activePools || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">Active Users</h3>
|
||||
<p className="text-2xl font-bold mt-2">{metrics.activeUsers || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">24h Transactions</h3>
|
||||
<p className="text-2xl font-bold mt-2">{metrics.transactionCount24h || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
151
frontend/components/analytics/PoolAnalytics.tsx
Normal file
151
frontend/components/analytics/PoolAnalytics.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
import { BarChart } from '@/components/charts/BarChart'
|
||||
import { PieChart } from '@/components/charts/PieChart'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
|
||||
|
||||
export function PoolAnalytics() {
|
||||
const [poolId, setPoolId] = useState<string>('')
|
||||
const [analytics, setAnalytics] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [systemMetrics, setSystemMetrics] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSystemMetrics()
|
||||
}, [])
|
||||
|
||||
const fetchSystemMetrics = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/analytics/metrics`)
|
||||
setSystemMetrics(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching system metrics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPoolAnalytics = async () => {
|
||||
if (!poolId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/analytics/pools`, {
|
||||
params: { poolId },
|
||||
})
|
||||
setAnalytics(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching pool analytics:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = analytics.map((a) => ({
|
||||
date: new Date(a.timestamp).toLocaleDateString(),
|
||||
tvl: parseFloat(a.tvl || '0'),
|
||||
volume24h: parseFloat(a.volume24h || '0'),
|
||||
fees24h: parseFloat(a.fees24h || '0'),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System Metrics */}
|
||||
{systemMetrics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total TVL</h3>
|
||||
<p className="text-2xl font-bold mt-2">{parseFloat(systemMetrics.totalTVL || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">24h Volume</h3>
|
||||
<p className="text-2xl font-bold mt-2">{parseFloat(systemMetrics.totalVolume24h || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-sm font-medium text-gray-500">Active Pools</h3>
|
||||
<p className="text-2xl font-bold mt-2">{systemMetrics.activePools || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pool Selector */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={poolId}
|
||||
onChange={(e) => setPoolId(e.target.value)}
|
||||
placeholder="Enter Pool ID"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchPoolAnalytics}
|
||||
disabled={loading || !poolId}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load Analytics'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
{analytics.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
lines={[
|
||||
{ key: 'tvl', name: 'TVL', color: '#3b82f6' },
|
||||
{ key: 'volume24h', name: '24h Volume', color: '#10b981' },
|
||||
]}
|
||||
title="TVL and Volume Trends"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
bars={[{ key: 'fees24h', name: '24h Fees', color: '#f59e0b' }]}
|
||||
title="Fee Revenue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{analytics[0] && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<PieChart
|
||||
data={[
|
||||
{ name: 'Base Reserve', value: parseFloat(analytics[0].tvl || '0') * 0.5 },
|
||||
{ name: 'Quote Reserve', value: parseFloat(analytics[0].tvl || '0') * 0.5 },
|
||||
]}
|
||||
title="Reserve Distribution"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold mb-4">Pool Metrics</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Utilization Rate:</span>
|
||||
<span className="font-semibold">{(analytics[0].utilizationRate * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">7d Volume:</span>
|
||||
<span className="font-semibold">{parseFloat(analytics[0].volume7d || '0').toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">30d Volume:</span>
|
||||
<span className="font-semibold">{parseFloat(analytics[0].volume30d || '0').toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
147
frontend/components/analytics/PortfolioTracker.tsx
Normal file
147
frontend/components/analytics/PortfolioTracker.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AreaChart } from '@/components/charts/AreaChart'
|
||||
import { PieChart } from '@/components/charts/PieChart'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
|
||||
|
||||
interface PortfolioTrackerProps {
|
||||
userAddress: string
|
||||
}
|
||||
|
||||
export function PortfolioTracker({ userAddress }: PortfolioTrackerProps) {
|
||||
const [portfolio, setPortfolio] = useState<any>(null)
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (userAddress) {
|
||||
fetchPortfolio()
|
||||
fetchHistory()
|
||||
}
|
||||
}, [userAddress])
|
||||
|
||||
const fetchPortfolio = async () => {
|
||||
if (!userAddress) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/analytics/portfolio/${userAddress}`)
|
||||
setPortfolio(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!userAddress) return
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/analytics/portfolio/${userAddress}`, {
|
||||
params: {
|
||||
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
})
|
||||
setHistory(Array.isArray(response.data) ? response.data : [response.data])
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAddress) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow text-center text-gray-500">
|
||||
Please connect your wallet to view your portfolio
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow text-center">
|
||||
Loading portfolio...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!portfolio) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow text-center text-gray-500">
|
||||
No portfolio data available
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const historyData = history.map((h) => ({
|
||||
date: new Date(h.timestamp).toLocaleDateString(),
|
||||
value: parseFloat(h.totalValue || '0'),
|
||||
}))
|
||||
|
||||
const poolPositions = Object.values(portfolio.poolPositions || {}).map((pos: any) => ({
|
||||
name: `Pool ${pos.poolId}`,
|
||||
value: parseFloat(pos.value || '0'),
|
||||
}))
|
||||
|
||||
const vaultPositions = Object.values(portfolio.vaultPositions || {}).map((vault: any) => ({
|
||||
name: `Vault ${vault.vaultId}`,
|
||||
value: parseFloat(vault.value || '0'),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Portfolio Summary */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-4">Portfolio Overview</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Value</h3>
|
||||
<p className="text-3xl font-bold mt-2">{parseFloat(portfolio.totalValue || '0').toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Pool Positions</h3>
|
||||
<p className="text-3xl font-bold mt-2">{Object.keys(portfolio.poolPositions || {}).length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Vault Positions</h3>
|
||||
<p className="text-3xl font-bold mt-2">{Object.keys(portfolio.vaultPositions || {}).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Value History */}
|
||||
{historyData.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<AreaChart
|
||||
data={historyData}
|
||||
dataKey="date"
|
||||
areas={[{ key: 'value', name: 'Portfolio Value', color: '#3b82f6' }]}
|
||||
title="Portfolio Value Over Time"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset Allocation */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{poolPositions.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<PieChart
|
||||
data={poolPositions}
|
||||
title="Pool Positions"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{vaultPositions.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<PieChart
|
||||
data={vaultPositions}
|
||||
title="Vault Positions"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
55
frontend/components/analytics/RealTimeMetrics.tsx
Normal file
55
frontend/components/analytics/RealTimeMetrics.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRealtimeData } from '@/hooks/useRealtimeData'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
|
||||
export function RealTimeMetrics() {
|
||||
const { data, connected } = useRealtimeData('metrics')
|
||||
const [metrics, setMetrics] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setMetrics((prev) => [...prev.slice(-50), data].slice(-50))
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const chartData = metrics.map((m, index) => ({
|
||||
time: index.toString(),
|
||||
tvl: parseFloat(m?.totalTVL || '0'),
|
||||
volume: parseFloat(m?.totalVolume24h || '0'),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold">Real-Time Metrics</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-gray-600">{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
<LineChart
|
||||
data={chartData}
|
||||
dataKey="time"
|
||||
lines={[
|
||||
{ key: 'tvl', name: 'TVL', color: '#3b82f6' },
|
||||
{ key: 'volume', name: 'Volume', color: '#10b981' },
|
||||
]}
|
||||
title="Real-Time Metrics"
|
||||
/>
|
||||
)}
|
||||
|
||||
{metrics.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Waiting for real-time data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
47
frontend/components/charts/AreaChart.tsx
Normal file
47
frontend/components/charts/AreaChart.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface AreaChartProps {
|
||||
data: Array<Record<string, any>>
|
||||
dataKey: string
|
||||
areas: Array<{ key: string; name: string; color?: string }>
|
||||
title?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function AreaChart({ data, dataKey, areas, title, height = 300 }: AreaChartProps) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsAreaChart data={data}>
|
||||
<defs>
|
||||
{areas.map((area) => (
|
||||
<linearGradient key={area.key} id={`color${area.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={area.color || '#3b82f6'} stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor={area.color || '#3b82f6'} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey={dataKey} stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{areas.map((area) => (
|
||||
<Area
|
||||
key={area.key}
|
||||
type="monotone"
|
||||
dataKey={area.key}
|
||||
name={area.name}
|
||||
stroke={area.color || '#3b82f6'}
|
||||
fill={`url(#color${area.key})`}
|
||||
/>
|
||||
))}
|
||||
</RechartsAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
37
frontend/components/charts/BarChart.tsx
Normal file
37
frontend/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface BarChartProps {
|
||||
data: Array<Record<string, any>>
|
||||
dataKey: string
|
||||
bars: Array<{ key: string; name: string; color?: string }>
|
||||
title?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function BarChart({ data, dataKey, bars, title, height = 300 }: BarChartProps) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey={dataKey} stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.key}
|
||||
dataKey={bar.key}
|
||||
name={bar.name}
|
||||
fill={bar.color || '#3b82f6'}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
24
frontend/components/charts/ChartTooltip.tsx
Normal file
24
frontend/components/charts/ChartTooltip.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active?: boolean
|
||||
payload?: any[]
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-3 border border-gray-200 rounded-lg shadow-lg">
|
||||
<p className="font-semibold mb-2">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} style={{ color: entry.color }} className="text-sm">
|
||||
{entry.name}: {typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
39
frontend/components/charts/LineChart.tsx
Normal file
39
frontend/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface LineChartProps {
|
||||
data: Array<Record<string, any>>
|
||||
dataKey: string
|
||||
lines: Array<{ key: string; name: string; color?: string }>
|
||||
title?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function LineChart({ data, dataKey, lines, title, height = 300 }: LineChartProps) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey={dataKey} stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{lines.map((line) => (
|
||||
<Line
|
||||
key={line.key}
|
||||
type="monotone"
|
||||
dataKey={line.key}
|
||||
name={line.name}
|
||||
stroke={line.color || '#3b82f6'}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
frontend/components/charts/PieChart.tsx
Normal file
41
frontend/components/charts/PieChart.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface PieChartProps {
|
||||
data: Array<{ name: string; value: number }>
|
||||
title?: string
|
||||
height?: number
|
||||
colors?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
|
||||
|
||||
export function PieChart({ data, title, height = 300, colors = DEFAULT_COLORS }: PieChartProps) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
152
frontend/components/governance/ProposalDiscussion.tsx
Normal file
152
frontend/components/governance/ProposalDiscussion.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useAccount } from 'wagmi'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
|
||||
|
||||
interface Comment {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
parentId?: string
|
||||
upvotes: number
|
||||
downvotes: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ProposalDiscussionProps {
|
||||
proposalId: string
|
||||
}
|
||||
|
||||
export function ProposalDiscussion({ proposalId }: ProposalDiscussionProps) {
|
||||
const { address } = useAccount()
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments()
|
||||
}, [proposalId])
|
||||
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/governance/discussion/${proposalId}`)
|
||||
setComments(response.data.comments || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim() || !address) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await axios.post(`${API_URL}/api/governance/discussion/${proposalId}/comment`, {
|
||||
author: address,
|
||||
content: newComment,
|
||||
})
|
||||
setNewComment('')
|
||||
fetchComments()
|
||||
} catch (error) {
|
||||
console.error('Error adding comment:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (commentId: string, upvote: boolean) => {
|
||||
if (!address) return
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/api/governance/discussion/comment/${commentId}/vote`, {
|
||||
voter: address,
|
||||
upvote,
|
||||
})
|
||||
fetchComments()
|
||||
} catch (error) {
|
||||
console.error('Error voting on comment:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelComments = comments.filter((c) => !c.parentId)
|
||||
const replies = (parentId: string) => comments.filter((c) => c.parentId === parentId)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold">Discussion ({comments.length})</h3>
|
||||
|
||||
{/* Comment Form */}
|
||||
{address && (
|
||||
<form onSubmit={handleSubmit} className="bg-white p-4 rounded-lg shadow">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md mb-4"
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newComment.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
<div className="space-y-4">
|
||||
{topLevelComments.map((comment) => (
|
||||
<div key={comment.id} className="bg-white p-4 rounded-lg shadow">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-semibold">{comment.author.slice(0, 10)}...</span>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-3">{comment.content}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => handleVote(comment.id, true)}
|
||||
className="flex items-center gap-1 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
▲ {comment.upvotes}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVote(comment.id, false)}
|
||||
className="flex items-center gap-1 text-gray-600 hover:text-red-600"
|
||||
>
|
||||
▼ {comment.downvotes}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{replies(comment.id).length > 0 && (
|
||||
<div className="mt-4 ml-8 space-y-2 border-l-2 border-gray-200 pl-4">
|
||||
{replies(comment.id).map((reply) => (
|
||||
<div key={reply.id} className="bg-gray-50 p-3 rounded">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm">{reply.author.slice(0, 10)}...</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(reply.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{reply.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user