mirror of
https://github.com/pezkuwichain/pezkuwi-sdk-ui.git
synced 2026-08-02 08:25:40 +00:00
fix: update @pezkuwi/api packages to 16.5.22
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"name": "multiformats",
|
||||
"version": "12.1.3",
|
||||
"description": "Interface for multihash, multicodec, multibase and CID",
|
||||
"author": "Mikeal Rogers <mikeal.rogers@gmail.com> (https://www.mikealrogers.com/)",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"homepage": "https://github.com/multiformats/js-multiformats#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/multiformats/js-multiformats.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/multiformats/js-multiformats/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ipfs",
|
||||
"ipld",
|
||||
"multiformats"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "./dist/types/src/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"*",
|
||||
"dist/types/*",
|
||||
"dist/types/src/*",
|
||||
"dist/types/src/*/index"
|
||||
],
|
||||
"src/*": [
|
||||
"*",
|
||||
"dist/types/*",
|
||||
"dist/types/src/*",
|
||||
"dist/types/src/*/index"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"CHANGELOG.md",
|
||||
"examples",
|
||||
"LICENSE*",
|
||||
"src",
|
||||
"test",
|
||||
"tsconfig.json",
|
||||
"dist",
|
||||
"vendor",
|
||||
"!**/*.tsbuildinfo",
|
||||
"!test/ts-use/node_modules"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/src/index.d.ts",
|
||||
"import": "./src/index.js"
|
||||
},
|
||||
"./bases/base10": {
|
||||
"types": "./dist/types/src/bases/base10.d.ts",
|
||||
"import": "./src/bases/base10.js"
|
||||
},
|
||||
"./bases/base16": {
|
||||
"types": "./dist/types/src/bases/base16.d.ts",
|
||||
"import": "./src/bases/base16.js"
|
||||
},
|
||||
"./bases/base2": {
|
||||
"types": "./dist/types/src/bases/base2.d.ts",
|
||||
"import": "./src/bases/base2.js"
|
||||
},
|
||||
"./bases/base256emoji": {
|
||||
"types": "./dist/types/src/bases/base256emoji.d.ts",
|
||||
"import": "./src/bases/base256emoji.js"
|
||||
},
|
||||
"./bases/base32": {
|
||||
"types": "./dist/types/src/bases/base32.d.ts",
|
||||
"import": "./src/bases/base32.js"
|
||||
},
|
||||
"./bases/base36": {
|
||||
"types": "./dist/types/src/bases/base36.d.ts",
|
||||
"import": "./src/bases/base36.js"
|
||||
},
|
||||
"./bases/base58": {
|
||||
"types": "./dist/types/src/bases/base58.d.ts",
|
||||
"import": "./src/bases/base58.js"
|
||||
},
|
||||
"./bases/base64": {
|
||||
"types": "./dist/types/src/bases/base64.d.ts",
|
||||
"import": "./src/bases/base64.js"
|
||||
},
|
||||
"./bases/base8": {
|
||||
"types": "./dist/types/src/bases/base8.d.ts",
|
||||
"import": "./src/bases/base8.js"
|
||||
},
|
||||
"./bases/identity": {
|
||||
"types": "./dist/types/src/bases/identity.d.ts",
|
||||
"import": "./src/bases/identity.js"
|
||||
},
|
||||
"./bases/interface": {
|
||||
"types": "./dist/types/src/bases/interface.d.ts",
|
||||
"import": "./src/bases/interface.js"
|
||||
},
|
||||
"./basics": {
|
||||
"types": "./dist/types/src/basics.d.ts",
|
||||
"import": "./src/basics.js"
|
||||
},
|
||||
"./block": {
|
||||
"types": "./dist/types/src/block.d.ts",
|
||||
"import": "./src/block.js"
|
||||
},
|
||||
"./block/interface": {
|
||||
"types": "./dist/types/src/block/interface.d.ts",
|
||||
"import": "./src/block/interface.js"
|
||||
},
|
||||
"./bytes": {
|
||||
"types": "./dist/types/src/bytes.d.ts",
|
||||
"import": "./src/bytes.js"
|
||||
},
|
||||
"./cid": {
|
||||
"types": "./dist/types/src/cid.d.ts",
|
||||
"import": "./src/cid.js"
|
||||
},
|
||||
"./codecs/interface": {
|
||||
"types": "./dist/types/src/codecs/interface.d.ts",
|
||||
"import": "./src/codecs/interface.js"
|
||||
},
|
||||
"./codecs/json": {
|
||||
"types": "./dist/types/src/codecs/json.d.ts",
|
||||
"import": "./src/codecs/json.js"
|
||||
},
|
||||
"./codecs/raw": {
|
||||
"types": "./dist/types/src/codecs/raw.d.ts",
|
||||
"import": "./src/codecs/raw.js"
|
||||
},
|
||||
"./hashes/digest": {
|
||||
"types": "./dist/types/src/hashes/digest.d.ts",
|
||||
"import": "./src/hashes/digest.js"
|
||||
},
|
||||
"./hashes/hasher": {
|
||||
"types": "./dist/types/src/hashes/hasher.d.ts",
|
||||
"import": "./src/hashes/hasher.js"
|
||||
},
|
||||
"./hashes/identity": {
|
||||
"types": "./dist/types/src/hashes/identity.d.ts",
|
||||
"import": "./src/hashes/identity.js"
|
||||
},
|
||||
"./hashes/interface": {
|
||||
"types": "./dist/types/src/hashes/interface.d.ts",
|
||||
"import": "./src/hashes/interface.js"
|
||||
},
|
||||
"./hashes/sha1": {
|
||||
"types": "./dist/types/src/hashes/sha1.d.ts",
|
||||
"browser": "./src/hashes/sha1-browser.js",
|
||||
"import": "./src/hashes/sha1.js"
|
||||
},
|
||||
"./hashes/sha2": {
|
||||
"types": "./dist/types/src/hashes/sha2.d.ts",
|
||||
"browser": "./src/hashes/sha2-browser.js",
|
||||
"import": "./src/hashes/sha2.js"
|
||||
},
|
||||
"./interface": {
|
||||
"types": "./dist/types/src/interface.d.ts",
|
||||
"import": "./src/interface.js"
|
||||
},
|
||||
"./link": {
|
||||
"types": "./dist/types/src/link.d.ts",
|
||||
"import": "./src/link.js"
|
||||
},
|
||||
"./link/interface": {
|
||||
"types": "./dist/types/src/link/interface.d.ts",
|
||||
"import": "./src/link/interface.js"
|
||||
},
|
||||
"./traversal": {
|
||||
"types": "./dist/types/src/traversal.d.ts",
|
||||
"import": "./src/traversal.js"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"./hashes/sha1": "./src/hashes/sha1-browser.js",
|
||||
"./src/hashes/sha1.js": "./src/hashes/sha1-browser.js",
|
||||
"./hashes/sha2": "./src/hashes/sha2-browser.js",
|
||||
"./src/hashes/sha2.js": "./src/hashes/sha2-browser.js"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "ipfs",
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"branches": [
|
||||
"master"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{
|
||||
"breaking": true,
|
||||
"release": "major"
|
||||
},
|
||||
{
|
||||
"revert": true,
|
||||
"release": "patch"
|
||||
},
|
||||
{
|
||||
"type": "feat",
|
||||
"release": "minor"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"release": "patch"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"release": "patch"
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"release": "patch"
|
||||
},
|
||||
{
|
||||
"type": "deps",
|
||||
"release": "patch"
|
||||
},
|
||||
{
|
||||
"scope": "no-release",
|
||||
"release": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"presetConfig": {
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "Trivial Changes"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "Documentation"
|
||||
},
|
||||
{
|
||||
"type": "deps",
|
||||
"section": "Dependencies"
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"section": "Tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/github",
|
||||
"@semantic-release/git"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "aegir clean",
|
||||
"lint": "aegir lint",
|
||||
"build": "aegir build",
|
||||
"release": "aegir release",
|
||||
"docs": "aegir docs",
|
||||
"test": "npm run lint && npm run test:node && npm run test:chrome && npm run test:ts",
|
||||
"test:ts": "npm run test --prefix test/ts-use",
|
||||
"test:node": "aegir test -t node --cov",
|
||||
"test:chrome": "aegir test -t browser --cov",
|
||||
"test:chrome-webworker": "aegir test -t webworker",
|
||||
"test:firefox": "aegir test -t browser -- --browser firefox",
|
||||
"test:firefox-webworker": "aegir test -t webworker -- --browser firefox",
|
||||
"test:electron-main": "aegir test -t electron-main"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stablelib/sha256": "^1.0.1",
|
||||
"@stablelib/sha512": "^1.0.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"aegir": "^41.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"cids": "^1.1.9",
|
||||
"crypto-hash": "^3.0.0"
|
||||
},
|
||||
"aegir": {
|
||||
"test": {
|
||||
"target": [
|
||||
"node",
|
||||
"browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -97,11 +97,11 @@
|
||||
"source-map-explorer": "^2.5.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@pezkuwi/api": "^16.5.11",
|
||||
"@pezkuwi/api-augment": "^16.5.11",
|
||||
"@pezkuwi/api-base": "^16.5.11",
|
||||
"@pezkuwi/api-contract": "^16.5.11",
|
||||
"@pezkuwi/api-derive": "^16.5.11",
|
||||
"@pezkuwi/api": "^16.5.22",
|
||||
"@pezkuwi/api-augment": "^16.5.22",
|
||||
"@pezkuwi/api-base": "^16.5.22",
|
||||
"@pezkuwi/api-contract": "^16.5.22",
|
||||
"@pezkuwi/api-derive": "^16.5.22",
|
||||
"@pezkuwi/extension-dapp": "0.62.20",
|
||||
"@pezkuwi/extension-inject": "^0.62.20",
|
||||
"@pezkuwi/hw-ledger": "^14.0.11",
|
||||
@@ -110,9 +110,9 @@
|
||||
"@pezkuwi/phishing": "^0.25.28",
|
||||
"@pezkuwi/rpc-augment": "^16.5.11",
|
||||
"@pezkuwi/rpc-core": "^16.5.11",
|
||||
"@pezkuwi/rpc-provider": "^16.5.11",
|
||||
"@pezkuwi/rpc-provider": "^16.5.22",
|
||||
"@pezkuwi/typegen": "^16.5.11",
|
||||
"@pezkuwi/types": "^16.5.11",
|
||||
"@pezkuwi/types": "^16.5.22",
|
||||
"@pezkuwi/types-augment": "^16.5.11",
|
||||
"@pezkuwi/types-codec": "^16.5.11",
|
||||
"@pezkuwi/types-create": "^16.5.11",
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
"@parallel-finance/type-definitions": "2.0.1",
|
||||
"@peaqnetwork/type-definitions": "0.0.4",
|
||||
"@pendulum-chain/type-definitions": "0.3.8",
|
||||
"@pezkuwi/api": "^16.5.2",
|
||||
"@pezkuwi/api-derive": "^16.5.2",
|
||||
"@pezkuwi/api": "^16.5.22",
|
||||
"@pezkuwi/api-derive": "^16.5.22",
|
||||
"@pezkuwi/networks": "^14.0.5",
|
||||
"@pezkuwi/react-identicon": "^3.17.1",
|
||||
"@pezkuwi/types": "^16.5.2",
|
||||
"@pezkuwi/types": "^16.5.22",
|
||||
"@pezkuwi/types-codec": "^16.5.2",
|
||||
"@pezkuwi/util": "^14.0.5",
|
||||
"@pezkuwi/util-crypto": "^14.0.5",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
'autoprefixer': {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 742 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 742 KiB |
@@ -0,0 +1,73 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { AppProvider } from '@/contexts/AppContext';
|
||||
import { PezkuwiProvider } from '@/contexts/PezkuwiContext';
|
||||
import { WalletProvider } from '@/contexts/WalletContext';
|
||||
import { WebSocketProvider } from '@/contexts/WebSocketContext';
|
||||
import { IdentityProvider } from '@/contexts/IdentityContext';
|
||||
import { AuthProvider } from '@/contexts/AuthContext';
|
||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
||||
import { ReferralProvider } from '@/contexts/ReferralContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { initSentry } from '@/lib/sentry';
|
||||
import './tailwind.css';
|
||||
import './i18n/config';
|
||||
|
||||
// Initialize Sentry error monitoring
|
||||
initSentry();
|
||||
|
||||
// Lazy load wallet-related pages
|
||||
const WalletDashboard = lazy(() => import('./pages/WalletDashboard'));
|
||||
const ProfileSettings = lazy(() => import('./pages/ProfileSettings'));
|
||||
const NotFound = lazy(() => import('@/pages/NotFound'));
|
||||
|
||||
// Loading component
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function ModernApp() {
|
||||
const endpoint = (process.env.WS_URL as string) || 'wss://beta-rpc.pezkuwichain.io:19944';
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AppProvider>
|
||||
<PezkuwiProvider endpoint={endpoint}>
|
||||
<WalletProvider>
|
||||
<WebSocketProvider>
|
||||
<IdentityProvider>
|
||||
<DashboardProvider>
|
||||
<ReferralProvider>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<WalletDashboard />} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<ProfileSettings />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ReferralProvider>
|
||||
</DashboardProvider>
|
||||
</IdentityProvider>
|
||||
</WebSocketProvider>
|
||||
</WalletProvider>
|
||||
</PezkuwiProvider>
|
||||
</AppProvider>
|
||||
</AuthProvider>
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModernApp;
|
||||
@@ -0,0 +1,789 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Wallet, TrendingUp, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
|
||||
import { AddTokenModal } from './AddTokenModal';
|
||||
import { TransferModal } from './TransferModal';
|
||||
import { getAllScores, type UserScores } from '@pezkuwi/lib/scores';
|
||||
|
||||
interface TokenBalance {
|
||||
assetId: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
balance: string;
|
||||
decimals: number;
|
||||
usdValue: number;
|
||||
}
|
||||
|
||||
export const AccountBalance: React.FC = () => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [balance, setBalance] = useState<{
|
||||
free: string;
|
||||
reserved: string;
|
||||
total: string;
|
||||
}>({
|
||||
free: '0',
|
||||
reserved: '0',
|
||||
total: '0',
|
||||
});
|
||||
const [pezBalance, setPezBalance] = useState<string>('0');
|
||||
const [usdtBalance, setUsdtBalance] = useState<string>('0');
|
||||
const [hezUsdPrice, setHezUsdPrice] = useState<number>(0);
|
||||
const [pezUsdPrice, setPezUsdPrice] = useState<number>(0);
|
||||
const [scores, setScores] = useState<UserScores>({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0
|
||||
});
|
||||
const [loadingScores, setLoadingScores] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [otherTokens, setOtherTokens] = useState<TokenBalance[]>([]);
|
||||
const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false);
|
||||
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false);
|
||||
const [selectedTokenForTransfer, setSelectedTokenForTransfer] = useState<TokenBalance | null>(null);
|
||||
const [customTokenIds, setCustomTokenIds] = useState<number[]>(() => {
|
||||
const stored = localStorage.getItem('customTokenIds');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
});
|
||||
|
||||
// Helper function to get asset decimals
|
||||
const getAssetDecimals = (assetId: number): number => {
|
||||
if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals
|
||||
return 12; // wHEZ, PEZ and others have 12 decimals by default
|
||||
};
|
||||
|
||||
// Helper to decode hex string to UTF-8
|
||||
const hexToString = (hex: string): string => {
|
||||
if (!hex || hex === '0x') return '';
|
||||
try {
|
||||
const hexStr = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []);
|
||||
return new TextDecoder('utf-8').decode(bytes).replace(/\0/g, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Token logo mapping
|
||||
const TOKEN_LOGOS: Record<string, string> = {
|
||||
HEZ: '/tokens/HEZ.png',
|
||||
PEZ: '/tokens/PEZ.png',
|
||||
USDT: '/tokens/USDT.png',
|
||||
wUSDT: '/tokens/USDT.png',
|
||||
BNB: '/tokens/BNB.png',
|
||||
BTC: '/tokens/BTC.png',
|
||||
DOT: '/tokens/DOT.png',
|
||||
ETH: '/tokens/ETH.png',
|
||||
};
|
||||
|
||||
// Get token logo URL
|
||||
const getTokenLogo = (symbol: string): string | null => {
|
||||
return TOKEN_LOGOS[symbol] || TOKEN_LOGOS[symbol.toUpperCase()] || null;
|
||||
};
|
||||
|
||||
// Get token color based on assetId
|
||||
const getTokenColor = (assetId: number) => {
|
||||
const colors = {
|
||||
[ASSET_IDS.WHEZ]: { bg: 'from-green-500/20 to-yellow-500/20', text: 'text-green-400', border: 'border-green-500/30' },
|
||||
[ASSET_IDS.WUSDT]: { bg: 'from-emerald-500/20 to-teal-500/20', text: 'text-emerald-400', border: 'border-emerald-500/30' },
|
||||
};
|
||||
return colors[assetId] || { bg: 'from-cyan-500/20 to-blue-500/20', text: 'text-cyan-400', border: 'border-cyan-500/30' };
|
||||
};
|
||||
|
||||
// Fetch token prices from pools using pool account ID
|
||||
const fetchTokenPrices = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('💰 Fetching token prices from pools...');
|
||||
|
||||
// Import utilities for pool account derivation
|
||||
const { stringToU8a } = await import('@pezkuwi/util');
|
||||
const { blake2AsU8a } = await import('@pezkuwi/util-crypto');
|
||||
const PALLET_ID = stringToU8a('py/ascon');
|
||||
|
||||
// Fetch wHEZ/wUSDT pool reserves (Asset 0 / Asset 1000)
|
||||
const whezPoolId = api.createType('(u32, u32)', [0, ASSET_IDS.WUSDT]);
|
||||
const whezPalletIdType = api.createType('[u8; 8]', PALLET_ID);
|
||||
const whezFullTuple = api.createType('([u8; 8], (u32, u32))', [whezPalletIdType, whezPoolId]);
|
||||
const whezAccountHash = blake2AsU8a(whezFullTuple.toU8a(), 256);
|
||||
const whezPoolAccountId = api.createType('AccountId32', whezAccountHash);
|
||||
|
||||
const whezReserve0Query = await api.query.assets.account(0, whezPoolAccountId);
|
||||
const whezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, whezPoolAccountId);
|
||||
|
||||
if (whezReserve0Query.isSome && whezReserve1Query.isSome) {
|
||||
const reserve0Data = whezReserve0Query.unwrap();
|
||||
const reserve1Data = whezReserve1Query.unwrap();
|
||||
|
||||
const reserve0 = BigInt(reserve0Data.balance.toString()); // wHEZ (12 decimals)
|
||||
const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals)
|
||||
|
||||
// Calculate price: 1 HEZ = ? USD
|
||||
const hezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6));
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ HEZ price:', hezPrice, 'USD');
|
||||
setHezUsdPrice(hezPrice);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('⚠️ wHEZ/wUSDT pool has no reserves');
|
||||
}
|
||||
|
||||
// Fetch PEZ/wUSDT pool reserves (Asset 1 / Asset 1000)
|
||||
const pezPoolId = api.createType('(u32, u32)', [1, ASSET_IDS.WUSDT]);
|
||||
const pezPalletIdType = api.createType('[u8; 8]', PALLET_ID);
|
||||
const pezFullTuple = api.createType('([u8; 8], (u32, u32))', [pezPalletIdType, pezPoolId]);
|
||||
const pezAccountHash = blake2AsU8a(pezFullTuple.toU8a(), 256);
|
||||
const pezPoolAccountId = api.createType('AccountId32', pezAccountHash);
|
||||
|
||||
const pezReserve0Query = await api.query.assets.account(1, pezPoolAccountId);
|
||||
const pezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, pezPoolAccountId);
|
||||
|
||||
if (pezReserve0Query.isSome && pezReserve1Query.isSome) {
|
||||
const reserve0Data = pezReserve0Query.unwrap();
|
||||
const reserve1Data = pezReserve1Query.unwrap();
|
||||
|
||||
const reserve0 = BigInt(reserve0Data.balance.toString()); // PEZ (12 decimals)
|
||||
const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals)
|
||||
|
||||
// Calculate price: 1 PEZ = ? USD
|
||||
const pezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6));
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ PEZ price:', pezPrice, 'USD');
|
||||
setPezUsdPrice(pezPrice);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('⚠️ PEZ/wUSDT pool has no reserves');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Failed to fetch token prices:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch other tokens (only custom tokens - wrapped tokens are backend-only)
|
||||
const fetchOtherTokens = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
const tokens: TokenBalance[] = [];
|
||||
|
||||
// IMPORTANT: Only show custom tokens added by user
|
||||
// Wrapped tokens (wHEZ, wUSDT) are for backend operations only
|
||||
// Core tokens (HEZ, PEZ) are shown in their own dedicated cards
|
||||
const assetIdsToCheck = customTokenIds.filter((id) =>
|
||||
id !== ASSET_IDS.WHEZ && // Exclude wrapped tokens
|
||||
id !== ASSET_IDS.WUSDT &&
|
||||
id !== ASSET_IDS.PEZ // Exclude core tokens
|
||||
);
|
||||
|
||||
for (const assetId of assetIdsToCheck) {
|
||||
try {
|
||||
const assetBalance = await api.query.assets.account(assetId, selectedAccount.address);
|
||||
const assetMetadata = await api.query.assets.metadata(assetId);
|
||||
|
||||
if (assetBalance.isSome) {
|
||||
const assetData = assetBalance.unwrap();
|
||||
const balance = assetData.balance.toString();
|
||||
|
||||
const metadata = assetMetadata.toJSON() as { symbol?: string; name?: string; decimals?: number };
|
||||
|
||||
// Decode hex strings properly
|
||||
let symbol = metadata.symbol || '';
|
||||
let name = metadata.name || '';
|
||||
|
||||
if (typeof symbol === 'string' && symbol.startsWith('0x')) {
|
||||
symbol = hexToString(symbol);
|
||||
}
|
||||
if (typeof name === 'string' && name.startsWith('0x')) {
|
||||
name = hexToString(name);
|
||||
}
|
||||
|
||||
// Fallback to known symbols if metadata is empty
|
||||
if (!symbol || symbol.trim() === '') {
|
||||
symbol = getAssetSymbol(assetId);
|
||||
}
|
||||
if (!name || name.trim() === '') {
|
||||
name = symbol;
|
||||
}
|
||||
|
||||
const decimals = metadata.decimals || getAssetDecimals(assetId);
|
||||
const balanceFormatted = (parseInt(balance) / Math.pow(10, decimals)).toFixed(6);
|
||||
|
||||
// Simple USD calculation (would use real price feed in production)
|
||||
let usdValue = 0;
|
||||
if (assetId === ASSET_IDS.WUSDT) {
|
||||
usdValue = parseFloat(balanceFormatted); // 1 wUSDT = 1 USD
|
||||
} else if (assetId === ASSET_IDS.WHEZ) {
|
||||
usdValue = parseFloat(balanceFormatted) * 0.5; // Placeholder price
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
assetId,
|
||||
symbol: symbol.trim(),
|
||||
name: name.trim(),
|
||||
balance: balanceFormatted,
|
||||
decimals,
|
||||
usdValue
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error(`Failed to fetch token ${assetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setOtherTokens(tokens);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch other tokens:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBalance = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Fetch HEZ balance
|
||||
const { data: balanceData } = await api.query.system.account(selectedAccount.address);
|
||||
|
||||
const free = balanceData.free.toString();
|
||||
const reserved = balanceData.reserved.toString();
|
||||
|
||||
// Convert from plancks to tokens (12 decimals)
|
||||
const decimals = 12;
|
||||
const divisor = Math.pow(10, decimals);
|
||||
|
||||
const freeTokens = (parseInt(free) / divisor).toFixed(4);
|
||||
const reservedTokens = (parseInt(reserved) / divisor).toFixed(4);
|
||||
const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4);
|
||||
|
||||
setBalance({
|
||||
free: freeTokens,
|
||||
reserved: reservedTokens,
|
||||
total: totalTokens,
|
||||
});
|
||||
|
||||
// Fetch PEZ balance (Asset ID: 1)
|
||||
try {
|
||||
const pezAssetBalance = await api.query.assets.account(1, selectedAccount.address);
|
||||
|
||||
if (pezAssetBalance.isSome) {
|
||||
const assetData = pezAssetBalance.unwrap();
|
||||
const pezAmount = assetData.balance.toString();
|
||||
const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4);
|
||||
setPezBalance(pezTokens);
|
||||
} else {
|
||||
setPezBalance('0');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch PEZ balance:', error);
|
||||
setPezBalance('0');
|
||||
}
|
||||
|
||||
// Fetch USDT balance (wUSDT - Asset ID: 1000)
|
||||
try {
|
||||
const usdtAssetBalance = await api.query.assets.account(ASSET_IDS.WUSDT, selectedAccount.address);
|
||||
|
||||
if (usdtAssetBalance.isSome) {
|
||||
const assetData = usdtAssetBalance.unwrap();
|
||||
const usdtAmount = assetData.balance.toString();
|
||||
const usdtDecimals = 6; // wUSDT has 6 decimals
|
||||
const usdtDivisor = Math.pow(10, usdtDecimals);
|
||||
const usdtTokens = (parseInt(usdtAmount) / usdtDivisor).toFixed(2);
|
||||
setUsdtBalance(usdtTokens);
|
||||
} else {
|
||||
setUsdtBalance('0');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch USDT balance:', error);
|
||||
setUsdtBalance('0');
|
||||
}
|
||||
|
||||
// Fetch token prices from pools
|
||||
await fetchTokenPrices();
|
||||
|
||||
// Fetch other tokens
|
||||
await fetchOtherTokens();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch balance:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add custom token handler
|
||||
const handleAddToken = async (assetId: number) => {
|
||||
if (customTokenIds.includes(assetId)) {
|
||||
alert('Token already added!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update custom tokens list
|
||||
const updatedTokenIds = [...customTokenIds, assetId];
|
||||
setCustomTokenIds(updatedTokenIds);
|
||||
localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds));
|
||||
|
||||
// Fetch the new token
|
||||
await fetchOtherTokens();
|
||||
setIsAddTokenModalOpen(false);
|
||||
};
|
||||
|
||||
// Remove token handler (unused but kept for future feature)
|
||||
// const handleRemoveToken = (assetId: number) => {
|
||||
// const updatedTokenIds = customTokenIds.filter(id => id !== assetId);
|
||||
// setCustomTokenIds(updatedTokenIds);
|
||||
// localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds));
|
||||
//
|
||||
// // Remove from displayed tokens
|
||||
// setOtherTokens(prev => prev.filter(t => t.assetId !== assetId));
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
fetchBalance();
|
||||
fetchTokenPrices(); // Fetch token USD prices from pools
|
||||
|
||||
// Fetch All Scores from blockchain
|
||||
const fetchAllScores = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount?.address) {
|
||||
setScores({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingScores(true);
|
||||
try {
|
||||
const userScores = await getAllScores(api, selectedAccount.address);
|
||||
setScores(userScores);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch scores:', err);
|
||||
setScores({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0
|
||||
});
|
||||
} finally {
|
||||
setLoadingScores(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllScores();
|
||||
|
||||
// Subscribe to HEZ balance updates
|
||||
let unsubscribeHez: () => void;
|
||||
let unsubscribePez: () => void;
|
||||
let unsubscribeUsdt: () => void;
|
||||
|
||||
const subscribeBalance = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) return;
|
||||
|
||||
// Subscribe to HEZ balance
|
||||
unsubscribeHez = await api.query.system.account(
|
||||
selectedAccount.address,
|
||||
({ data: balanceData }) => {
|
||||
const free = balanceData.free.toString();
|
||||
const reserved = balanceData.reserved.toString();
|
||||
|
||||
const decimals = 12;
|
||||
const divisor = Math.pow(10, decimals);
|
||||
|
||||
const freeTokens = (parseInt(free) / divisor).toFixed(4);
|
||||
const reservedTokens = (parseInt(reserved) / divisor).toFixed(4);
|
||||
const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4);
|
||||
|
||||
setBalance({
|
||||
free: freeTokens,
|
||||
reserved: reservedTokens,
|
||||
total: totalTokens,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to PEZ balance (Asset ID: 1)
|
||||
try {
|
||||
unsubscribePez = await api.query.assets.account(
|
||||
1,
|
||||
selectedAccount.address,
|
||||
(assetBalance) => {
|
||||
if (assetBalance.isSome) {
|
||||
const assetData = assetBalance.unwrap();
|
||||
const pezAmount = assetData.balance.toString();
|
||||
const decimals = 12;
|
||||
const divisor = Math.pow(10, decimals);
|
||||
const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4);
|
||||
setPezBalance(pezTokens);
|
||||
} else {
|
||||
setPezBalance('0');
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to PEZ balance:', error);
|
||||
}
|
||||
|
||||
// Subscribe to USDT balance (wUSDT - Asset ID: 2)
|
||||
try {
|
||||
unsubscribeUsdt = await api.query.assets.account(
|
||||
2,
|
||||
selectedAccount.address,
|
||||
(assetBalance) => {
|
||||
if (assetBalance.isSome) {
|
||||
const assetData = assetBalance.unwrap();
|
||||
const usdtAmount = assetData.balance.toString();
|
||||
const decimals = 6; // wUSDT has 6 decimals
|
||||
const divisor = Math.pow(10, decimals);
|
||||
const usdtTokens = (parseInt(usdtAmount) / divisor).toFixed(2);
|
||||
setUsdtBalance(usdtTokens);
|
||||
} else {
|
||||
setUsdtBalance('0');
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to USDT balance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
subscribeBalance();
|
||||
|
||||
return () => {
|
||||
if (unsubscribeHez) unsubscribeHez();
|
||||
if (unsubscribePez) unsubscribePez();
|
||||
if (unsubscribeUsdt) unsubscribeUsdt();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-400">
|
||||
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Connect your wallet to view balance</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* HEZ Balance Card */}
|
||||
<Card className="bg-gradient-to-br from-green-900/30 to-yellow-900/30 border-green-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/tokens/HEZ.png" alt="HEZ" className="w-10 h-10 rounded-full" />
|
||||
<CardTitle className="text-lg font-medium text-gray-300">
|
||||
HEZ Balance
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={fetchBalance}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-white mb-1">
|
||||
{isLoading ? '...' : balance.total}
|
||||
<span className="text-2xl text-gray-400 ml-2">HEZ</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{hezUsdPrice > 0
|
||||
? `≈ $${(parseFloat(balance.total) * hezUsdPrice).toFixed(2)} USD`
|
||||
: 'Price loading...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
<span className="text-xs text-gray-400">Transferable</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{balance.free} HEZ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ArrowDownRight className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-xs text-gray-400">Reserved</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{balance.reserved} HEZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* PEZ Balance Card */}
|
||||
<Card className="bg-gradient-to-br from-blue-900/30 to-purple-900/30 border-blue-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/tokens/PEZ.png" alt="PEZ" className="w-10 h-10 rounded-full" />
|
||||
<CardTitle className="text-lg font-medium text-gray-300">
|
||||
PEZ Token Balance
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-white mb-1">
|
||||
{isLoading ? '...' : pezBalance}
|
||||
<span className="text-2xl text-gray-400 ml-2">PEZ</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{pezUsdPrice > 0
|
||||
? `≈ $${(parseFloat(pezBalance) * pezUsdPrice).toFixed(2)} USD`
|
||||
: 'Price loading...'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Governance & Rewards Token
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* USDT Balance Card */}
|
||||
<Card className="bg-gradient-to-br from-emerald-900/30 to-teal-900/30 border-emerald-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/tokens/USDT.png" alt="USDT" className="w-10 h-10 rounded-full" />
|
||||
<CardTitle className="text-lg font-medium text-gray-300">
|
||||
USDT Balance
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-white mb-1">
|
||||
{isLoading ? '...' : usdtBalance}
|
||||
<span className="text-2xl text-gray-400 ml-2">USDT</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
≈ ${usdtBalance} USD • Stablecoin
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Info & Scores */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-medium text-gray-300">
|
||||
Account Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Account Details */}
|
||||
<div className="space-y-2 pb-4 border-b border-gray-800">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Account</span>
|
||||
<span className="text-white font-mono">
|
||||
{selectedAccount.meta.name || 'Unnamed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Address</span>
|
||||
<span className="text-white font-mono text-xs">
|
||||
{selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scores from Blockchain */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-3">Scores from Blockchain</div>
|
||||
{loadingScores ? (
|
||||
<div className="text-sm text-gray-400">Loading scores...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Score Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Shield className="h-3 w-3 text-purple-400" />
|
||||
<span className="text-xs text-gray-400">Trust</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-purple-400">{scores.trustScore}</span>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Users className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-xs text-gray-400">Referral</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-cyan-400">{scores.referralScore}</span>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-400" />
|
||||
<span className="text-xs text-gray-400">Staking</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-green-400">{scores.stakingScore}</span>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Award className="h-3 w-3 text-pink-400" />
|
||||
<span className="text-xs text-gray-400">Tiki</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-pink-400">{scores.tikiScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Score */}
|
||||
<div className="pt-3 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Total Score</span>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
{scores.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Other Tokens */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-5 w-5 text-cyan-400" />
|
||||
<CardTitle className="text-lg font-medium text-gray-300">
|
||||
Other Assets
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsAddTokenModalOpen(true)}
|
||||
className="text-cyan-400 hover:text-cyan-300 hover:bg-cyan-400/10"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{otherTokens.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Coins className="w-12 h-12 text-gray-600 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-gray-500 text-sm">No custom tokens yet</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Add custom tokens to track additional assets
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{otherTokens.map((token) => {
|
||||
const tokenColor = getTokenColor(token.assetId);
|
||||
return (
|
||||
<div
|
||||
key={token.assetId}
|
||||
className="flex items-center justify-between p-4 bg-gray-800/50 rounded-lg hover:bg-gray-800/70 transition-all duration-200 group border border-transparent hover:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Token Logo */}
|
||||
{getTokenLogo(token.symbol) ? (
|
||||
<img
|
||||
src={getTokenLogo(token.symbol)!}
|
||||
alt={token.symbol}
|
||||
className="w-12 h-12 rounded-full shadow-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-12 h-12 rounded-full bg-gradient-to-br ${tokenColor.bg} flex items-center justify-center border ${tokenColor.border} shadow-lg`}>
|
||||
<span className={`text-base font-bold ${tokenColor.text}`}>
|
||||
{token.symbol.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-white">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
#{token.assetId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{token.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-base font-semibold text-white">
|
||||
{parseFloat(token.balance).toFixed(4)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
${token.usdValue.toFixed(2)} USD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedTokenForTransfer(token);
|
||||
setIsTransferModalOpen(true);
|
||||
}}
|
||||
className={`${tokenColor.text} hover:${tokenColor.text} hover:bg-gray-700/50 opacity-0 group-hover:opacity-100 transition-opacity`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Token Modal */}
|
||||
<AddTokenModal
|
||||
isOpen={isAddTokenModalOpen}
|
||||
onClose={() => setIsAddTokenModalOpen(false)}
|
||||
onAddToken={handleAddToken}
|
||||
/>
|
||||
|
||||
{/* Transfer Modal */}
|
||||
<TransferModal
|
||||
isOpen={isTransferModalOpen}
|
||||
onClose={() => {
|
||||
setIsTransferModalOpen(false);
|
||||
setSelectedTokenForTransfer(null);
|
||||
}}
|
||||
selectedAsset={selectedTokenForTransfer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Info, AlertCircle } from 'lucide-react';
|
||||
import { web3FromAddress } from '@pezkuwi/extension-dapp';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
|
||||
|
||||
interface AddLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset0?: number; // Pool's first asset ID
|
||||
asset1?: number; // Pool's second asset ID
|
||||
}
|
||||
|
||||
interface AssetDetails {
|
||||
minBalance?: string | number;
|
||||
}
|
||||
|
||||
interface AssetAccountData {
|
||||
balance: string | number;
|
||||
}
|
||||
|
||||
interface Balances {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
// Helper to get display name (users see HEZ not wHEZ, PEZ, USDT not wUSDT)
|
||||
const getDisplayName = (assetId: number): string => {
|
||||
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
|
||||
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
|
||||
if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT';
|
||||
return getAssetSymbol(assetId);
|
||||
};
|
||||
|
||||
// Helper to get balance key for the asset
|
||||
const getBalanceKey = (assetId: number): string => {
|
||||
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
|
||||
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
|
||||
if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT';
|
||||
return getAssetSymbol(assetId);
|
||||
};
|
||||
|
||||
// Helper to get decimals for asset
|
||||
const getAssetDecimals = (assetId: number): number => {
|
||||
if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 6; // wUSDT has 6 decimals
|
||||
return 12; // wHEZ, PEZ have 12 decimals
|
||||
};
|
||||
|
||||
export const AddLiquidityModal: React.FC<AddLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset0 = 0, // Default to wHEZ
|
||||
asset1 = 1 // Default to PEZ
|
||||
}) => {
|
||||
const { api, selectedAccount, isApiReady } = usePezkuwi();
|
||||
const { balances, refreshBalances } = useWallet();
|
||||
|
||||
const [amount0, setAmount0] = useState('');
|
||||
const [amount1, setAmount1] = useState('');
|
||||
const [currentPrice, setCurrentPrice] = useState<number | null>(null);
|
||||
const [isPoolEmpty, setIsPoolEmpty] = useState(true); // Track if pool has meaningful liquidity
|
||||
const [minDeposit0, setMinDeposit0] = useState<number>(0.01); // Dynamic minimum deposit for asset0
|
||||
const [minDeposit1, setMinDeposit1] = useState<number>(0.01); // Dynamic minimum deposit for asset1
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Get asset details
|
||||
const asset0Name = getDisplayName(asset0);
|
||||
const asset1Name = getDisplayName(asset1);
|
||||
const asset0BalanceKey = getBalanceKey(asset0);
|
||||
const asset1BalanceKey = getBalanceKey(asset1);
|
||||
const asset0Decimals = getAssetDecimals(asset0);
|
||||
const asset1Decimals = getAssetDecimals(asset1);
|
||||
|
||||
// Reset form when modal is closed
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setAmount0('');
|
||||
setAmount1('');
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch minimum deposit requirements from runtime
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !isOpen) return;
|
||||
|
||||
const fetchMinimumBalances = async () => {
|
||||
try {
|
||||
// Query asset details which contains minBalance
|
||||
const assetDetails0 = await api.query.assets.asset(asset0);
|
||||
const assetDetails1 = await api.query.assets.asset(asset1);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔍 Querying minimum balances for assets:', { asset0, asset1 });
|
||||
|
||||
if (assetDetails0.isSome && assetDetails1.isSome) {
|
||||
const details0 = assetDetails0.unwrap().toJSON() as AssetDetails;
|
||||
const details1 = assetDetails1.unwrap().toJSON() as AssetDetails;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📦 Asset details:', {
|
||||
asset0: details0,
|
||||
asset1: details1
|
||||
});
|
||||
|
||||
const minBalance0Raw = details0.minBalance || '0';
|
||||
const minBalance1Raw = details1.minBalance || '0';
|
||||
|
||||
const minBalance0 = Number(minBalance0Raw) / Math.pow(10, asset0Decimals);
|
||||
const minBalance1 = Number(minBalance1Raw) / Math.pow(10, asset1Decimals);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📊 Minimum deposit requirements from assets:', {
|
||||
asset0: asset0Name,
|
||||
minBalance0Raw,
|
||||
minBalance0,
|
||||
asset1: asset1Name,
|
||||
minBalance1Raw,
|
||||
minBalance1
|
||||
});
|
||||
|
||||
setMinDeposit0(minBalance0);
|
||||
setMinDeposit1(minBalance1);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('⚠️ Asset details not found, using defaults');
|
||||
}
|
||||
|
||||
// Also check if there's a MintMinLiquidity constant in assetConversion pallet
|
||||
if (api.consts.assetConversion) {
|
||||
const mintMinLiq = api.consts.assetConversion.mintMinLiquidity;
|
||||
if (mintMinLiq) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔧 AssetConversion MintMinLiquidity constant:', mintMinLiq.toString());
|
||||
}
|
||||
|
||||
const liquidityWithdrawalFee = api.consts.assetConversion.liquidityWithdrawalFee;
|
||||
if (liquidityWithdrawalFee) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔧 AssetConversion LiquidityWithdrawalFee:', liquidityWithdrawalFee.toHuman());
|
||||
}
|
||||
|
||||
// Log all assetConversion constants
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔧 All assetConversion constants:', Object.keys(api.consts.assetConversion));
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Error fetching minimum balances:', err);
|
||||
// Keep default 0.01 if query fails
|
||||
}
|
||||
};
|
||||
|
||||
fetchMinimumBalances();
|
||||
}, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals, asset0Name, asset1Name]);
|
||||
|
||||
// Fetch current pool price
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !isOpen) return;
|
||||
|
||||
const fetchPoolPrice = async () => {
|
||||
try {
|
||||
const poolId = [asset0, asset1];
|
||||
const poolInfo = await api.query.assetConversion.pools(poolId);
|
||||
|
||||
if (poolInfo.isSome) {
|
||||
// Derive pool account using AccountIdConverter
|
||||
const { stringToU8a } = await import('@pezkuwi/util');
|
||||
const { blake2AsU8a } = await import('@pezkuwi/util-crypto');
|
||||
|
||||
const PALLET_ID = stringToU8a('py/ascon');
|
||||
const poolIdType = api.createType('(u32, u32)', [asset0, asset1]);
|
||||
const palletIdType = api.createType('[u8; 8]', PALLET_ID);
|
||||
const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolIdType]);
|
||||
|
||||
const accountHash = blake2AsU8a(fullTuple.toU8a(), 256);
|
||||
const poolAccountId = api.createType('AccountId32', accountHash);
|
||||
|
||||
// Get reserves
|
||||
const balance0Data = await api.query.assets.account(asset0, poolAccountId);
|
||||
const balance1Data = await api.query.assets.account(asset1, poolAccountId);
|
||||
|
||||
if (balance0Data.isSome && balance1Data.isSome) {
|
||||
const data0 = balance0Data.unwrap().toJSON() as AssetAccountData;
|
||||
const data1 = balance1Data.unwrap().toJSON() as AssetAccountData;
|
||||
|
||||
const reserve0 = Number(data0.balance) / Math.pow(10, asset0Decimals);
|
||||
const reserve1 = Number(data1.balance) / Math.pow(10, asset1Decimals);
|
||||
|
||||
// Consider pool empty if reserves are less than 1 token (dust amounts)
|
||||
const MINIMUM_LIQUIDITY = 1;
|
||||
if (reserve0 >= MINIMUM_LIQUIDITY && reserve1 >= MINIMUM_LIQUIDITY) {
|
||||
setCurrentPrice(reserve1 / reserve0);
|
||||
setIsPoolEmpty(false);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Pool has liquidity - auto-calculating ratio:', reserve1 / reserve0);
|
||||
} else {
|
||||
setCurrentPrice(null);
|
||||
setIsPoolEmpty(true);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Pool is empty or has dust only - manual input allowed');
|
||||
}
|
||||
} else {
|
||||
// No reserves found - pool is empty
|
||||
setCurrentPrice(null);
|
||||
setIsPoolEmpty(true);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Pool is empty - manual input allowed');
|
||||
}
|
||||
} else {
|
||||
// Pool doesn't exist yet - completely empty
|
||||
setCurrentPrice(null);
|
||||
setIsPoolEmpty(true);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Pool does not exist yet - manual input allowed');
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching pool price:', err);
|
||||
// On error, assume pool is empty to allow manual input
|
||||
setCurrentPrice(null);
|
||||
setIsPoolEmpty(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPoolPrice();
|
||||
}, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals]);
|
||||
|
||||
// Auto-calculate asset1 amount based on asset0 input (only if pool has liquidity)
|
||||
useEffect(() => {
|
||||
if (!isPoolEmpty && amount0 && currentPrice) {
|
||||
const calculated = parseFloat(amount0) * currentPrice;
|
||||
setAmount1(calculated.toFixed(asset1Decimals === 6 ? 2 : 4));
|
||||
} else if (!amount0 && !isPoolEmpty) {
|
||||
setAmount1('');
|
||||
}
|
||||
// If pool is empty, don't auto-calculate - let user input both amounts
|
||||
}, [amount0, currentPrice, asset1Decimals, isPoolEmpty]);
|
||||
|
||||
const handleAddLiquidity = async () => {
|
||||
if (!api || !selectedAccount || !amount0 || !amount1) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate amounts
|
||||
if (parseFloat(amount0) <= 0 || parseFloat(amount1) <= 0) {
|
||||
setError('Please enter valid amounts');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check minimum deposit requirements from runtime
|
||||
if (parseFloat(amount0) < minDeposit0) {
|
||||
setError(`${asset0Name} amount must be at least ${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseFloat(amount1) < minDeposit1) {
|
||||
setError(`${asset1Name} amount must be at least ${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const balance0 = (balances as Balances)[asset0BalanceKey] || 0;
|
||||
const balance1 = (balances as Balances)[asset1BalanceKey] || 0;
|
||||
|
||||
if (parseFloat(amount0) > balance0) {
|
||||
setError(`Insufficient ${asset0Name} balance`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseFloat(amount1) > balance1) {
|
||||
setError(`Insufficient ${asset1Name} balance`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the signer from the extension
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Convert amounts to proper decimals
|
||||
const amount0BN = BigInt(Math.floor(parseFloat(amount0) * Math.pow(10, asset0Decimals)));
|
||||
const amount1BN = BigInt(Math.floor(parseFloat(amount1) * Math.pow(10, asset1Decimals)));
|
||||
|
||||
// Min amounts (90% of desired to account for slippage)
|
||||
const minAmount0BN = (amount0BN * BigInt(90)) / BigInt(100);
|
||||
const minAmount1BN = (amount1BN * BigInt(90)) / BigInt(100);
|
||||
|
||||
// Build transaction(s)
|
||||
let tx;
|
||||
|
||||
// If asset0 is HEZ (0), need to wrap it first
|
||||
if (asset0 === 0 || asset0 === ASSET_IDS.WHEZ) {
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(amount0BN.toString());
|
||||
|
||||
const addLiquidityTx = api.tx.assetConversion.addLiquidity(
|
||||
asset0,
|
||||
asset1,
|
||||
amount0BN.toString(),
|
||||
amount1BN.toString(),
|
||||
minAmount0BN.toString(),
|
||||
minAmount1BN.toString(),
|
||||
selectedAccount.address
|
||||
);
|
||||
|
||||
// Batch wrap + add liquidity
|
||||
tx = api.tx.utility.batchAll([wrapTx, addLiquidityTx]);
|
||||
} else {
|
||||
// Direct add liquidity (no wrapping needed)
|
||||
tx = api.tx.assetConversion.addLiquidity(
|
||||
asset0,
|
||||
asset1,
|
||||
amount0BN.toString(),
|
||||
amount1BN.toString(),
|
||||
minAmount0BN.toString(),
|
||||
minAmount1BN.toString(),
|
||||
selectedAccount.address
|
||||
);
|
||||
}
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, events, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction in block:', status.asInBlock.toHex());
|
||||
} else if (status.isFinalized) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction finalized:', status.asFinalized.toHex());
|
||||
|
||||
// Check for errors
|
||||
const hasError = events.some(({ event }) =>
|
||||
api.events.system.ExtrinsicFailed.is(event)
|
||||
);
|
||||
|
||||
if (hasError || dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
const { docs, name, section } = decoded;
|
||||
errorMessage = `${section}.${name}: ${docs.join(' ')}`;
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Dispatch error:', errorMessage);
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Dispatch error:', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
events.forEach(({ event }) => {
|
||||
if (api.events.system.ExtrinsicFailed.is(event)) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('ExtrinsicFailed event:', event.toHuman());
|
||||
}
|
||||
});
|
||||
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction successful');
|
||||
setSuccess(true);
|
||||
setIsLoading(false);
|
||||
setAmount0('');
|
||||
setAmount1('');
|
||||
refreshBalances();
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error adding liquidity:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to add liquidity');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const balance0 = (balances as Balances)[asset0BalanceKey] || 0;
|
||||
const balance1 = (balances as Balances)[asset1BalanceKey] || 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6 border border-gray-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Add Liquidity</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert className="mb-4 bg-red-900/20 border-red-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="mb-4 bg-green-900/20 border-green-500">
|
||||
<AlertDescription>Liquidity added successfully!</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isPoolEmpty ? (
|
||||
<Alert className="mb-4 bg-yellow-900/20 border-yellow-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<strong>First Liquidity Provider:</strong> Pool is empty! You are setting the initial price ratio.
|
||||
<strong> Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}.</strong>
|
||||
{(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert className="mb-4 bg-blue-900/20 border-blue-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Add liquidity to earn 3% fees from all swaps. Amounts are auto-calculated based on current pool ratio.
|
||||
<strong> Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}.</strong>
|
||||
{(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Asset 0 Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{asset0Name} Amount
|
||||
<span className="text-xs text-gray-500 ml-2">(min: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)})</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={amount0}
|
||||
onChange={(e) => setAmount0(e.target.value)}
|
||||
placeholder={`${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} or more`}
|
||||
min={minDeposit0}
|
||||
step={minDeposit0 < 1 ? minDeposit0 : 0.01}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
<span className="text-gray-400 text-sm">{asset0Name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Balance: {balance0.toLocaleString()}</span>
|
||||
<button
|
||||
onClick={() => setAmount0(balance0.toString())}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Plus className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* Asset 1 Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{asset1Name} Amount {!isPoolEmpty && '(Auto-calculated)'}
|
||||
{isPoolEmpty && (
|
||||
<>
|
||||
<span className="text-yellow-400 text-xs ml-2">⚠️ You set the initial ratio</span>
|
||||
<span className="text-xs text-gray-500 ml-2">(min: {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)})</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={amount1}
|
||||
onChange={(e) => setAmount1(e.target.value)}
|
||||
placeholder={isPoolEmpty ? `${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} or more` : "Auto-calculated"}
|
||||
min={isPoolEmpty ? minDeposit1 : undefined}
|
||||
step={isPoolEmpty ? (minDeposit1 < 1 ? minDeposit1 : 0.01) : undefined}
|
||||
className={`w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 focus:outline-none ${
|
||||
isPoolEmpty
|
||||
? 'text-white focus:border-blue-500'
|
||||
: 'text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!isPoolEmpty || isLoading}
|
||||
readOnly={!isPoolEmpty}
|
||||
/>
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
<span className="text-gray-400 text-sm">{asset1Name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Balance: {balance1.toLocaleString()}</span>
|
||||
{isPoolEmpty ? (
|
||||
<button
|
||||
onClick={() => setAmount1(balance1.toString())}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
) : (
|
||||
currentPrice && <span>Rate: 1 {asset0Name} = {currentPrice.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Info */}
|
||||
{amount0 && amount1 && (
|
||||
<div className="bg-gray-800 rounded-lg p-3 space-y-2 text-sm">
|
||||
{isPoolEmpty && (
|
||||
<div className="flex justify-between text-yellow-300">
|
||||
<span>Initial Price</span>
|
||||
<span>
|
||||
1 {asset0Name} = {(parseFloat(amount1) / parseFloat(amount0)).toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Share of Pool</span>
|
||||
<span>{isPoolEmpty ? '100%' : '~0.1%'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Slippage Tolerance</span>
|
||||
<span>10%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAddLiquidity}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!amount0 ||
|
||||
!amount1 ||
|
||||
parseFloat(amount0) > balance0 ||
|
||||
parseFloat(amount1) > balance1
|
||||
}
|
||||
className="w-full bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 h-12"
|
||||
>
|
||||
{isLoading ? 'Adding Liquidity...' : 'Add Liquidity'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface AddTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddToken: (assetId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddTokenModal: React.FC<AddTokenModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddToken,
|
||||
}) => {
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const id = parseInt(assetId);
|
||||
if (isNaN(id) || id < 0) {
|
||||
setError('Please enter a valid asset ID (number)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAddToken(id);
|
||||
setAssetId('');
|
||||
setError('');
|
||||
} catch {
|
||||
setError('Failed to add token. Please check the asset ID and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAssetId('');
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Add Custom Token</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Enter the asset ID of the token you want to track.
|
||||
Note: Core tokens (HEZ, PEZ) are already displayed separately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assetId" className="text-sm text-gray-300">
|
||||
Asset ID
|
||||
</Label>
|
||||
<Input
|
||||
id="assetId"
|
||||
type="number"
|
||||
value={assetId}
|
||||
onChange={(e) => setAssetId(e.target.value)}
|
||||
placeholder="e.g., 3"
|
||||
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Each token on the network has a unique asset ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="border border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add Token'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,582 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import HeroSection from './HeroSection';
|
||||
import TokenomicsSection from './TokenomicsSection';
|
||||
import PalletsGrid from './PalletsGrid';
|
||||
import ChainSpecs from './ChainSpecs';
|
||||
import TrustScoreCalculator from './TrustScoreCalculator';
|
||||
import { NetworkStats } from './NetworkStats';
|
||||
import { WalletModal } from './wallet/WalletModal';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import NotificationBell from './notifications/NotificationBell';
|
||||
import ProposalWizard from './proposals/ProposalWizard';
|
||||
import DelegationManager from './delegation/DelegationManager';
|
||||
import { ForumOverview } from './forum/ForumOverview';
|
||||
import { ModerationPanel } from './forum/ModerationPanel';
|
||||
import { TreasuryOverview } from './treasury/TreasuryOverview';
|
||||
import { FundingProposal } from './treasury/FundingProposal';
|
||||
import { SpendingHistory } from './treasury/SpendingHistory';
|
||||
import { MultiSigApproval } from './treasury/MultiSigApproval';
|
||||
import { ExternalLink, Award, FileEdit, Users2, MessageSquare, ShieldCheck, Wifi, WifiOff, Wallet, DollarSign, PiggyBank, History, Key, TrendingUp, ArrowRightLeft, Lock, LogIn, LayoutDashboard, Settings, Users, Droplet, Mail, Coins } from 'lucide-react';
|
||||
import GovernanceInterface from './GovernanceInterface';
|
||||
import RewardDistribution from './RewardDistribution';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useWebSocket } from '@/contexts/WebSocketContext';
|
||||
import { StakingDashboard } from './staking/StakingDashboard';
|
||||
import { MultiSigWallet } from './wallet/MultiSigWallet';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { PezkuwiWalletButton } from './PezkuwiWalletButton';
|
||||
import { DEXDashboard } from './dex/DEXDashboard';
|
||||
import { P2PDashboard } from './p2p/P2PDashboard';
|
||||
import EducationPlatform from '../pages/EducationPlatform';
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [walletModalOpen, setWalletModalOpen] = useState(false);
|
||||
const { user, signOut } = useAuth();
|
||||
const [showProposalWizard, setShowProposalWizard] = useState(false);
|
||||
const [showDelegation, setShowDelegation] = useState(false);
|
||||
const [showForum, setShowForum] = useState(false);
|
||||
const [showModeration, setShowModeration] = useState(false);
|
||||
const [showTreasury, setShowTreasury] = useState(false);
|
||||
const [treasuryTab, setTreasuryTab] = useState('overview');
|
||||
const [showStaking, setShowStaking] = useState(false);
|
||||
const [showMultiSig, setShowMultiSig] = useState(false);
|
||||
const [showDEX, setShowDEX] = useState(false);
|
||||
const [showEducation, setShowEducation] = useState(false);
|
||||
const [showP2P, setShowP2P] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { isConnected } = useWebSocket();
|
||||
useWallet();
|
||||
const [, _setIsAdmin] = useState(false);
|
||||
|
||||
// Check if user is admin
|
||||
React.useEffect(() => {
|
||||
const checkAdminStatus = async () => {
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Admin check error:', error);
|
||||
}
|
||||
_setIsAdmin(!!data);
|
||||
} else {
|
||||
_setIsAdmin(false);
|
||||
}
|
||||
};
|
||||
checkAdminStatus();
|
||||
}, [user]);
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-40 bg-gray-950/90 backdrop-blur-md border-b border-gray-800">
|
||||
<div className="w-full px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* LEFT: Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-green-500 to-yellow-400 bg-clip-text text-transparent whitespace-nowrap">
|
||||
PezkuwiChain
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CENTER & RIGHT: Menu + Actions in same row */}
|
||||
<div className="hidden lg:flex items-center space-x-4 flex-1 justify-start ml-8 pr-4">
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/wallet')}
|
||||
className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Wallet className="w-4 h-4" />
|
||||
Wallet
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/be-citizen')}
|
||||
className="text-cyan-300 hover:text-cyan-100 transition-colors flex items-center gap-1 text-sm font-semibold"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Be Citizen
|
||||
</button>
|
||||
|
||||
{/* Governance Dropdown */}
|
||||
<div className="relative group">
|
||||
<button className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm">
|
||||
<FileEdit className="w-4 h-4" />
|
||||
Governance
|
||||
<svg className="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute left-0 mt-2 w-48 bg-gray-900 border border-gray-700 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<button
|
||||
onClick={() => setShowProposalWizard(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2 rounded-t-lg"
|
||||
>
|
||||
<FileEdit className="w-4 h-4" />
|
||||
Proposals
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDelegation(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<Users2 className="w-4 h-4" />
|
||||
Delegation
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForum(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Forum
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTreasury(true);
|
||||
setTreasuryTab('overview');
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<PiggyBank className="w-4 h-4" />
|
||||
Treasury
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModeration(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2 rounded-b-lg"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Moderation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Dropdown */}
|
||||
<div className="relative group">
|
||||
<button className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm">
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
Trading
|
||||
<svg className="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute left-0 mt-2 w-48 bg-gray-900 border border-gray-700 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<button
|
||||
onClick={() => setShowDEX(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2 rounded-t-lg"
|
||||
>
|
||||
<Droplet className="w-4 h-4" />
|
||||
DEX Pools
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowP2P(true);
|
||||
navigate('/p2p');
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
P2P
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/presale')}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<Coins className="w-4 h-4" />
|
||||
Presale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowStaking(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Staking
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMultiSig(true)}
|
||||
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2 rounded-b-lg"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
MultiSig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEducation(true);
|
||||
navigate('/education');
|
||||
}}
|
||||
className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Award className="w-4 h-4" />
|
||||
Education
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/profile/settings')}
|
||||
className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
}}
|
||||
className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 rotate-180" />
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate('/be-citizen')}
|
||||
className="text-cyan-300 hover:text-cyan-100 transition-colors flex items-center gap-1 text-sm font-semibold"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Be Citizen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Login
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/docs"
|
||||
className="text-gray-300 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-700"></div>
|
||||
|
||||
{/* Actions continue after Docs */}
|
||||
<div className="flex items-center">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NotificationBell />
|
||||
<LanguageSwitcher />
|
||||
<PezkuwiWalletButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
{/* Conditional Rendering for Features */}
|
||||
{showDEX ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<DEXDashboard />
|
||||
</div>
|
||||
</div>
|
||||
) : showProposalWizard ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<ProposalWizard
|
||||
onComplete={(proposal) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Proposal created:', proposal);
|
||||
setShowProposalWizard(false);
|
||||
}}
|
||||
onCancel={() => setShowProposalWizard(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : showDelegation ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<DelegationManager />
|
||||
</div>
|
||||
</div>
|
||||
) : showForum ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<ForumOverview />
|
||||
</div>
|
||||
</div>
|
||||
) : showModeration ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<ModerationPanel />
|
||||
</div>
|
||||
</div>
|
||||
) : showTreasury ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-green-500 via-yellow-400 to-red-500 bg-clip-text text-transparent">
|
||||
{t('treasury.title', 'Treasury Management')}
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
|
||||
{t('treasury.subtitle', 'Track funds, submit proposals, and manage community resources')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={treasuryTab} onValueChange={setTreasuryTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-8">
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<PiggyBank className="w-4 h-4" />
|
||||
{t('treasury.overview', 'Overview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proposals" className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{t('treasury.proposals', 'Funding Proposals')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
{t('treasury.history', 'Spending History')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="approvals" className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
{t('treasury.approvals', 'Multi-Sig Approvals')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<TreasuryOverview />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="proposals" className="mt-6">
|
||||
<FundingProposal />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="mt-6">
|
||||
<SpendingHistory />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="approvals" className="mt-6">
|
||||
<MultiSigApproval />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
) : showStaking ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-green-500 via-yellow-400 to-red-500 bg-clip-text text-transparent">
|
||||
Staking Rewards
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
|
||||
Stake your tokens and earn rewards
|
||||
</p>
|
||||
</div>
|
||||
<StakingDashboard />
|
||||
</div>
|
||||
</div>
|
||||
) : showMultiSig ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-green-500 via-yellow-400 to-red-500 bg-clip-text text-transparent">
|
||||
Multi-Signature Wallet
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
|
||||
Secure your funds with multi-signature protection
|
||||
</p>
|
||||
</div>
|
||||
<MultiSigWallet />
|
||||
</div>
|
||||
</div>
|
||||
) : showEducation ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<EducationPlatform />
|
||||
</div>
|
||||
) : showP2P ? (
|
||||
<div className="pt-20 min-h-screen bg-gray-950">
|
||||
<P2PDashboard />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<HeroSection />
|
||||
<NetworkStats key="network-stats-live" />
|
||||
<PalletsGrid />
|
||||
<TokenomicsSection />
|
||||
|
||||
|
||||
<div id="trust-calculator">
|
||||
<TrustScoreCalculator />
|
||||
</div>
|
||||
<div id="chain-specs">
|
||||
<ChainSpecs />
|
||||
</div>
|
||||
<div id="governance">
|
||||
<GovernanceInterface />
|
||||
</div>
|
||||
<div id="rewards">
|
||||
<RewardDistribution />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{(showDEX || showProposalWizard || showDelegation || showForum || showModeration || showTreasury || showStaking || showMultiSig || showEducation || showP2P) && (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDEX(false);
|
||||
setShowProposalWizard(false);
|
||||
setShowDelegation(false);
|
||||
setShowForum(false);
|
||||
setShowModeration(false);
|
||||
setShowTreasury(false);
|
||||
setShowStaking(false);
|
||||
setShowMultiSig(false);
|
||||
setShowEducation(false);
|
||||
setShowP2P(false);
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-full shadow-lg flex items-center gap-2 transition-all"
|
||||
>
|
||||
← Back to Home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Wallet Modal */}
|
||||
<WalletModal isOpen={walletModalOpen} onClose={() => setWalletModalOpen(false)} />
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-950 border-t border-gray-800 py-12">
|
||||
<div className="max-w-full mx-auto px-4">
|
||||
{/* Contact Info */}
|
||||
<div className="mb-8 space-y-2 text-sm text-gray-400 text-center">
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
info@pezkuwichain.io
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
info@pezkuwichain.app
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 text-left">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 bg-gradient-to-r from-green-500 to-yellow-400 bg-clip-text text-transparent">
|
||||
PezkuwiChain
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Decentralized governance platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-4 text-left">About</h4>
|
||||
<ul className="space-y-2 text-left">
|
||||
<li>
|
||||
<a
|
||||
href="https://raw.githubusercontent.com/pezkuwichain/DKSweb/main/public/Whitepaper.pdf"
|
||||
download="Pezkuwi_Whitepaper.pdf"
|
||||
className="text-gray-400 hover:text-white text-sm inline-flex items-center"
|
||||
>
|
||||
Whitepaper
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/pezkuwichain" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
GitHub
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-4 text-left">Developers</h4>
|
||||
<ul className="space-y-2 text-left">
|
||||
<li>
|
||||
<a href="/api" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
API
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/developers" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
SDK
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-4 text-left">Community</h4>
|
||||
<ul className="space-y-2 text-left">
|
||||
<li>
|
||||
<a href="https://discord.gg/pezkuwichain" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
Discord
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/PezkuwiChain" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
Twitter/X
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://t.me/PezkuwiApp" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
Telegram
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/@SatoshiQazi" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
YouTube
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://facebook.com/profile.php?id=61582484611719" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm inline-flex items-center">
|
||||
Facebook
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-800 text-center">
|
||||
<p className="text-gray-400 text-sm">
|
||||
© 2024 PezkuwiChain. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Globe, TestTube, Code, Wifi, Copy, Check, ExternalLink, Compass, Book, Briefcase, FileCode, HandCoins, Users, Wrench, MessageCircle, GitFork } from 'lucide-react';
|
||||
|
||||
// ... (interface and const arrays remain the same) ...
|
||||
|
||||
interface ChainSpec {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'Live' | 'Development' | 'Local';
|
||||
icon: React.ReactNode;
|
||||
endpoint: string;
|
||||
chainId: string;
|
||||
validators: number;
|
||||
features: string[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
const chainSpecs: ChainSpec[] = [
|
||||
{
|
||||
id: 'mainnet',
|
||||
name: 'PezkuwiChain Mainnet',
|
||||
type: 'Live',
|
||||
icon: <Globe className="w-5 h-5" />,
|
||||
endpoint: 'wss://mainnet.pezkuwichain.io',
|
||||
chainId: '0x1234...abcd',
|
||||
validators: 100,
|
||||
features: ['Production', 'Real Tokenomics', 'Full Security'],
|
||||
color: 'from-purple-500 to-purple-600'
|
||||
},
|
||||
{
|
||||
id: 'staging',
|
||||
name: 'PezkuwiChain Staging',
|
||||
type: 'Live',
|
||||
icon: <Server className="w-5 h-5" />,
|
||||
endpoint: 'wss://staging.pezkuwichain.io',
|
||||
chainId: '0x5678...efgh',
|
||||
validators: 20,
|
||||
features: ['Pre-production', 'Testing Features', 'Beta Access'],
|
||||
color: 'from-cyan-500 to-cyan-600'
|
||||
},
|
||||
{
|
||||
id: 'testnet',
|
||||
name: 'Real Testnet',
|
||||
type: 'Live',
|
||||
icon: <TestTube className="w-5 h-5" />,
|
||||
endpoint: 'wss://testnet.pezkuwichain.io',
|
||||
chainId: '0x9abc...ijkl',
|
||||
validators: 8,
|
||||
features: ['Test Tokens', 'Full Features', 'Public Testing'],
|
||||
color: 'from-teal-500 to-teal-600'
|
||||
},
|
||||
{
|
||||
id: 'beta',
|
||||
name: 'Beta Testnet',
|
||||
type: 'Live',
|
||||
icon: <TestTube className="w-5 h-5" />,
|
||||
endpoint: 'wss://beta.pezkuwichain.io',
|
||||
chainId: '0xdef0...mnop',
|
||||
validators: 4,
|
||||
features: ['Experimental', 'New Features', 'Limited Access'],
|
||||
color: 'from-orange-500 to-orange-600'
|
||||
},
|
||||
{
|
||||
id: 'alfa',
|
||||
name: 'PezkuwiChain Alfa Testnet',
|
||||
type: 'Development',
|
||||
icon: <TestTube className="w-5 h-5" />,
|
||||
endpoint: 'ws://127.0.0.1:8844',
|
||||
chainId: 'pezkuwichain_alfa_testnet',
|
||||
validators: 4,
|
||||
features: ['4 Validators', 'Staking Active', 'Full Features'],
|
||||
color: 'from-purple-500 to-pink-600'
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
type: 'Development',
|
||||
icon: <Code className="w-5 h-5" />,
|
||||
endpoint: 'ws://127.0.0.1:9944',
|
||||
chainId: '0xlocal...dev',
|
||||
validators: 1,
|
||||
features: ['Single Node', 'Fast Block Time', 'Dev Tools'],
|
||||
color: 'from-green-500 to-green-600'
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local Testnet',
|
||||
type: 'Local',
|
||||
icon: <Wifi className="w-5 h-5" />,
|
||||
endpoint: 'ws://127.0.0.1:9945',
|
||||
chainId: '0xlocal...test',
|
||||
validators: 2,
|
||||
features: ['Multi-node', 'Local Testing', 'Custom Config'],
|
||||
color: 'from-indigo-500 to-indigo-600'
|
||||
}
|
||||
];
|
||||
|
||||
const subdomains = [
|
||||
{ name: 'Explorer', href: '/explorer', icon: <Compass />, external: false },
|
||||
{ name: 'Docs', href: '/docs', icon: <Book />, external: false },
|
||||
{ name: 'Wallet', href: '/wallet', icon: <Briefcase />, external: false },
|
||||
{ name: 'API', href: '/api', icon: <FileCode />, external: false },
|
||||
{ name: 'Faucet', href: '/faucet', icon: <HandCoins />, external: false },
|
||||
{ name: 'Developers', href: '/developers', icon: <Users />, external: false },
|
||||
{ name: 'Grants', href: '/grants', icon: <Wrench />, external: false },
|
||||
{ name: 'Wiki', href: '/wiki', icon: <MessageCircle />, external: false },
|
||||
{ name: 'Forum', href: '/forum', icon: <GitFork />, external: false },
|
||||
{ name: 'Telemetry', href: '/telemetry', icon: <Server />, external: false },
|
||||
]
|
||||
|
||||
const ChainSpecs: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [selectedSpec] = useState<ChainSpec>(chainSpecs[0]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gray-900/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
{t('chainSpecs.title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
{t('chainSpecs.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{chainSpecs.map((spec) => (
|
||||
<div
|
||||
key={spec.id}
|
||||
onClick={() => navigate(`/${spec.id}`)}
|
||||
className={`cursor-pointer p-4 rounded-xl border transition-all ${
|
||||
selectedSpec.id === spec.id
|
||||
? 'bg-gray-900 border-purple-500'
|
||||
: 'bg-gray-950/50 border-gray-800 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${spec.color} bg-opacity-20`}>
|
||||
{spec.icon}
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
spec.type === 'Live' ? 'bg-green-900/30 text-green-400' :
|
||||
spec.type === 'Development' ? 'bg-yellow-900/30 text-yellow-400' :
|
||||
'bg-blue-900/30 text-blue-400'
|
||||
}`}>
|
||||
{spec.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-white font-semibold mb-2">{spec.name}</h3>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-400">
|
||||
<Server className="w-3 h-3 mr-1" />
|
||||
<span>{spec.validators} validators</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Subdomains Box */}
|
||||
<div
|
||||
onClick={() => navigate('/subdomains')}
|
||||
className="md:col-span-2 lg:col-span-1 cursor-pointer p-4 rounded-xl border transition-all bg-gray-950/50 border-gray-800 hover:border-gray-700"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 bg-opacity-20">
|
||||
<Compass className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-900/30 text-gray-400">
|
||||
{t('chainSpecs.services')}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-2">{t('chainSpecs.subdomainsTitle')}</h3>
|
||||
<div className="flex items-center text-sm text-gray-400">
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
<span>{t('chainSpecs.availableServices', { count: subdomains.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Chain Details */}
|
||||
<div className="bg-gray-950/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white mb-4 flex items-center">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${selectedSpec.color} bg-opacity-20 mr-3`}>
|
||||
{selectedSpec.icon}
|
||||
</div>
|
||||
{selectedSpec.name}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm">{t('chainSpecs.websocketEndpoint')}</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<code className="flex-1 p-3 bg-gray-900 rounded-lg text-cyan-400 font-mono text-sm">
|
||||
{selectedSpec.endpoint}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(selectedSpec.endpoint, `endpoint-${selectedSpec.id}`)}
|
||||
className="ml-2 p-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{copiedId === `endpoint-${selectedSpec.id}` ?
|
||||
<Check className="w-5 h-5 text-green-400" /> :
|
||||
<Copy className="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm">{t('chainSpecs.chainId')}</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<code className="flex-1 p-3 bg-gray-900 rounded-lg text-purple-400 font-mono text-sm">
|
||||
{selectedSpec.chainId}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(selectedSpec.chainId, `chainid-${selectedSpec.id}`)}
|
||||
className="ml-2 p-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{copiedId === `chainid-${selectedSpec.id}` ?
|
||||
<Check className="w-5 h-5 text-green-400" /> :
|
||||
<Copy className="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/explorer')}
|
||||
className="w-full mt-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Compass className="w-4 h-4 mr-2" />
|
||||
{t('chainSpecs.viewExplorer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-white mb-4">{t('chainSpecs.availableSubdomains')}</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{subdomains.map(subdomain => (
|
||||
<div key={subdomain.name} onClick={() => navigate(subdomain.href)} className="flex items-center p-3 bg-gray-900 rounded-lg cursor-pointer hover:bg-gray-800 transition-colors">
|
||||
<div className="mr-3 text-cyan-400">{subdomain.icon}</div>
|
||||
<span className="font-semibold">{subdomain.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChainSpecs;
|
||||
@@ -0,0 +1,243 @@
|
||||
// ========================================
|
||||
// Error Boundary Component
|
||||
// ========================================
|
||||
// Catches React errors and displays fallback UI
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Error Boundary
|
||||
* Catches unhandled errors in React component tree
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary>
|
||||
* <App />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
// Update state so next render shows fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log error to console
|
||||
if (process.env.NODE_ENV !== 'production') console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Update state with error details
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// In production, you might want to log to an error reporting service
|
||||
// Example: Sentry.captureException(error);
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = (): void => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-2xl w-full">
|
||||
<CardContent className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500 mb-6">
|
||||
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<h2 className="text-xl font-bold mb-2 text-white">Something Went Wrong</h2>
|
||||
<p className="mb-4">
|
||||
An unexpected error occurred. We apologize for the inconvenience.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="mt-4 p-4 bg-gray-950 rounded border border-gray-700">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-gray-400 hover:text-gray-300">
|
||||
Error Details (for developers)
|
||||
</summary>
|
||||
<div className="mt-3 text-xs font-mono space-y-2">
|
||||
<div>
|
||||
<strong className="text-red-400">Error:</strong>
|
||||
<pre className="mt-1 text-gray-400 whitespace-pre-wrap">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
{this.state.errorInfo && (
|
||||
<div>
|
||||
<strong className="text-red-400">Component Stack:</strong>
|
||||
<pre className="mt-1 text-gray-400 whitespace-pre-wrap">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
onClick={this.handleReset}
|
||||
className="bg-green-600 hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={this.handleReload}
|
||||
variant="outline"
|
||||
className="border-gray-700 hover:bg-gray-800 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
<Button
|
||||
onClick={this.handleGoHome}
|
||||
variant="outline"
|
||||
className="border-gray-700 hover:bg-gray-800 flex items-center gap-2"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-500">
|
||||
If this problem persists, please contact support at{' '}
|
||||
<a
|
||||
href="mailto:info@pezkuwichain.io"
|
||||
className="text-green-400 hover:underline"
|
||||
>
|
||||
info@pezkuwichain.io
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No error, render children normally
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ROUTE-LEVEL ERROR BOUNDARY
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Smaller error boundary for individual routes
|
||||
* Less intrusive, doesn't take over the whole screen
|
||||
*/
|
||||
export const RouteErrorBoundary: React.FC<{
|
||||
children: ReactNode;
|
||||
routeName?: string;
|
||||
}> = ({ children, routeName = 'this page' }) => {
|
||||
const [hasError, setHasError] = React.useState(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Error loading {routeName}</strong>
|
||||
An error occurred while rendering this component.
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleReset} size="sm" className="bg-green-600 hover:bg-green-700">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<RouteErrorFallback routeName={routeName} onReset={handleReset} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const RouteErrorFallback: React.FC<{ routeName: string; onReset: () => void }> = ({
|
||||
routeName,
|
||||
onReset,
|
||||
}) => {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Error loading {routeName}</strong>
|
||||
An unexpected error occurred.
|
||||
<div className="mt-4">
|
||||
<Button onClick={onReset} size="sm" className="bg-green-600 hover:bg-green-700">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TrendingUp, FileText, Users, Shield, Vote, History } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import GovernanceOverview from './governance/GovernanceOverview';
|
||||
import ProposalsList from './governance/ProposalsList';
|
||||
import ElectionsInterface from './governance/ElectionsInterface';
|
||||
import DelegationManager from './delegation/DelegationManager';
|
||||
import MyVotes from './governance/MyVotes';
|
||||
import GovernanceHistory from './governance/GovernanceHistory';
|
||||
|
||||
const GovernanceInterface: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
return (
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4">
|
||||
<span className="bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
On-Chain Governance
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
|
||||
Participate in PezkuwiChain's decentralized governance. Vote on proposals, elect representatives, and shape the future of the network.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-3 lg:grid-cols-6 gap-2 bg-gray-900/50 p-1 rounded-lg">
|
||||
<TabsTrigger value="overview" className="flex items-center space-x-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Overview</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proposals" className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Proposals</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="elections" className="flex items-center space-x-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Elections</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="delegation" className="flex items-center space-x-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Delegation</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="voting" className="flex items-center space-x-2">
|
||||
<Vote className="w-4 h-4" />
|
||||
<span>My Votes</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center space-x-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>History</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<GovernanceOverview />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="proposals" className="mt-6">
|
||||
<ProposalsList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="elections" className="mt-6">
|
||||
<ElectionsInterface />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="delegation" className="mt-6">
|
||||
<DelegationManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="voting" className="mt-6">
|
||||
<MyVotes />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="mt-6">
|
||||
<GovernanceHistory />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GovernanceInterface;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, Shield } from 'lucide-react';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { useWallet } from '../contexts/WalletContext'; // Import useWallet
|
||||
import { formatBalance } from '@pezkuwi/lib/wallet';
|
||||
|
||||
const HeroSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { selectedAccount } = useWallet(); // Use selectedAccount from WalletContext
|
||||
const [stats, setStats] = useState({
|
||||
activeProposals: 0,
|
||||
totalVoters: 0,
|
||||
tokensStaked: '0',
|
||||
trustScore: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
let currentTrustScore = 0; // Default if not fetched or no account
|
||||
if (selectedAccount?.address) {
|
||||
try {
|
||||
// Assuming pallet-staking-score has a storage item for trust scores
|
||||
// The exact query might need adjustment based on chain metadata
|
||||
const rawTrustScore = await api.query.stakingScore.trustScore(selectedAccount.address);
|
||||
// Assuming trustScore is a simple number or a wrapper around it
|
||||
currentTrustScore = rawTrustScore.isSome ? rawTrustScore.unwrap().toNumber() : 0;
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch trust score:', err);
|
||||
currentTrustScore = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch active referenda
|
||||
let activeProposals = 0;
|
||||
try {
|
||||
const referendaCount = await api.query.referenda.referendumCount();
|
||||
activeProposals = referendaCount.toNumber();
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch referenda:', err);
|
||||
}
|
||||
|
||||
// Fetch total staked tokens
|
||||
let tokensStaked = '0';
|
||||
try {
|
||||
const currentEra = await api.query.staking.currentEra();
|
||||
if (currentEra.isSome) {
|
||||
const eraIndex = currentEra.unwrap().toNumber();
|
||||
const totalStake = await api.query.staking.erasTotalStake(eraIndex);
|
||||
const formatted = formatBalance(totalStake.toString());
|
||||
tokensStaked = `${formatted} HEZ`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch total stake:', err);
|
||||
}
|
||||
|
||||
// Count total voters from conviction voting
|
||||
let totalVoters = 0;
|
||||
try {
|
||||
// Get all voting keys and count unique voters
|
||||
const votingKeys = await api.query.convictionVoting.votingFor.keys();
|
||||
// Each key represents a unique (account, track) pair
|
||||
// Count unique accounts
|
||||
const uniqueAccounts = new Set(votingKeys.map(key => key.args[0].toString()));
|
||||
totalVoters = uniqueAccounts.size;
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch voters:', err);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
setStats({
|
||||
activeProposals,
|
||||
totalVoters,
|
||||
tokensStaked,
|
||||
trustScore: currentTrustScore
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Hero stats updated:', {
|
||||
activeProposals,
|
||||
totalVoters,
|
||||
tokensStaked,
|
||||
trustScore: currentTrustScore
|
||||
});
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch hero stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [api, isApiReady, selectedAccount]); // Add selectedAccount to dependencies
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-start overflow-hidden bg-gray-950">
|
||||
{/* Background Image */}
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src="/DKstate.png"
|
||||
alt="DKstate Background"
|
||||
className="w-full h-full object-cover opacity-30"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-green-600/20 via-gray-950/70 to-gray-950"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 w-full text-center">
|
||||
<div className="mb-8 inline-flex items-center px-4 py-2 rounded-full bg-green-600/20 backdrop-blur-sm border border-green-500/30">
|
||||
<Shield className="w-4 h-4 text-yellow-400 mr-2" />
|
||||
<span className="text-yellow-400 text-sm font-medium">Digital Kurdistan State v1.0</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-green-500 via-yellow-400 to-red-500 bg-clip-text text-transparent">
|
||||
PezkuwiChain
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-gray-300 mb-4 max-w-3xl mx-auto">
|
||||
{t('hero.title', 'Blockchain Governance Platform')}
|
||||
</p>
|
||||
<p className="text-lg text-gray-400 mb-12 max-w-2xl mx-auto">
|
||||
{t('hero.subtitle', 'Democratic and transparent governance with blockchain technology')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12 max-w-5xl mx-auto px-4">
|
||||
<div className="bg-gray-900/70 backdrop-blur-md rounded-xl border border-green-500/40 p-6 hover:border-green-400/60 transition-all">
|
||||
<div className="text-4xl font-bold text-green-400 mb-2">{stats.activeProposals}</div>
|
||||
<div className="text-sm text-gray-300 font-medium">{t('hero.stats.activeProposals', 'Active Proposals')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/70 backdrop-blur-md rounded-xl border border-yellow-400/40 p-6 hover:border-yellow-400/60 transition-all">
|
||||
<div className="text-4xl font-bold text-yellow-400 mb-2">{stats.totalVoters.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-300 font-medium">{t('hero.stats.totalVoters', 'Total Voters')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/70 backdrop-blur-md rounded-xl border border-red-500/40 p-6 hover:border-red-500/60 transition-all">
|
||||
<div className="text-4xl font-bold text-red-400 mb-2">{stats.tokensStaked}</div>
|
||||
<div className="text-sm text-gray-300 font-medium">{t('hero.stats.tokensStaked', 'Tokens Staked')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/70 backdrop-blur-md rounded-xl border border-green-500/40 p-6 hover:border-green-500/60 transition-all">
|
||||
<div className="text-4xl font-bold text-green-400 mb-2">{stats.trustScore}%</div>
|
||||
<div className="text-sm text-gray-300 font-medium">{t('hero.stats.trustScore', 'Trust Score')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center px-4">
|
||||
<button
|
||||
onClick={() => document.getElementById('governance')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="px-8 py-4 bg-gradient-to-r from-green-500 via-yellow-400 to-yellow-500 text-gray-900 font-bold rounded-lg hover:shadow-lg hover:shadow-yellow-400/50 transition-all transform hover:scale-105 flex items-center justify-center group"
|
||||
>
|
||||
{t('hero.exploreGovernance', 'Explore Governance')}
|
||||
<ChevronRight className="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => document.getElementById('governance')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="px-8 py-4 bg-gray-900/80 backdrop-blur-sm text-white font-semibold rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-gray-600 transition-all"
|
||||
>
|
||||
{t('hero.learnMore', 'Learn More')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
|
||||
interface KurdistanSunProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const KurdistanSun: React.FC<KurdistanSunProps> = ({ size = 200, className = '' }) => {
|
||||
return (
|
||||
<div className={`kurdistan-sun-container ${className}`} style={{ width: size, height: size }}>
|
||||
{/* Rotating colored halos */}
|
||||
<div className="sun-halos">
|
||||
{/* Green halo (outermost) */}
|
||||
<div className="halo halo-green" />
|
||||
{/* Red halo (middle) */}
|
||||
<div className="halo halo-red" />
|
||||
{/* Yellow halo (inner) */}
|
||||
<div className="halo halo-yellow" />
|
||||
</div>
|
||||
|
||||
{/* Kurdistan Sun with 21 rays */}
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className="kurdistan-sun-svg"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{/* Sun rays (21 rays for Kurdistan flag) */}
|
||||
<g className="sun-rays">
|
||||
{Array.from({ length: 21 }).map((_, i) => {
|
||||
const angle = (i * 360) / 21;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2="100"
|
||||
y2="20"
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(${angle} 100 100)`}
|
||||
className="ray"
|
||||
style={{
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Central white circle */}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="35"
|
||||
fill="white"
|
||||
className="sun-center"
|
||||
/>
|
||||
|
||||
{/* Inner glow */}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="35"
|
||||
fill="url(#sunGradient)"
|
||||
className="sun-glow"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="sunGradient">
|
||||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.8)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<style>{`
|
||||
.kurdistan-sun-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sun-halos {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.halo {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: rotate-halo 3s linear infinite;
|
||||
}
|
||||
|
||||
.halo-green {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #00FF00;
|
||||
border-bottom-color: #00FF00;
|
||||
animation-duration: 3s;
|
||||
}
|
||||
|
||||
.halo-red {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
border: 4px solid transparent;
|
||||
border-left-color: #FF0000;
|
||||
border-right-color: #FF0000;
|
||||
animation-duration: 2.5s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.halo-yellow {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #FFD700;
|
||||
border-bottom-color: #FFD700;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
@keyframes rotate-halo {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.kurdistan-sun-svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.sun-rays {
|
||||
animation: pulse-rays 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-rays {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ray {
|
||||
animation: ray-shine 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ray-shine {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sun-center {
|
||||
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
|
||||
.sun-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { languages } from '@/i18n/config';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
// Update document direction based on language
|
||||
const currentLang = languages[i18n.language as keyof typeof languages];
|
||||
if (currentLang) {
|
||||
document.documentElement.dir = currentLang.dir;
|
||||
document.documentElement.lang = i18n.language;
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng);
|
||||
const lang = languages[lng as keyof typeof languages];
|
||||
if (lang) {
|
||||
document.documentElement.dir = lang.dir;
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
};
|
||||
|
||||
const currentLanguage = languages[i18n.language as keyof typeof languages] || languages.en;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{currentLanguage.name}</span>
|
||||
<span className="text-lg">{currentLanguage.flag}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => changeLanguage(code)}
|
||||
className={`cursor-pointer ${i18n.language === code ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`}
|
||||
>
|
||||
<span className="text-lg mr-2">{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
const PezkuwiChainLogo: React.FC = () => {
|
||||
return (
|
||||
<img src="/PezkuwiChain_Logo_Horizontal_Green_White.png" alt="PezkuwiChain Logo" className="h-8" />
|
||||
);
|
||||
};
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const linkStyle = "text-white hover:text-green-400 transition-colors";
|
||||
const activeLinkStyle = { color: '#34D399' }; // green-400
|
||||
|
||||
return (
|
||||
<header className="bg-gray-900 text-white p-4 fixed top-0 left-0 right-0 z-[1000]">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<Link to="/">
|
||||
<PezkuwiChainLogo />
|
||||
</Link>
|
||||
<nav>
|
||||
<ul className="flex space-x-4">
|
||||
<li><NavLink to="/explorer" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Explorer</NavLink></li>
|
||||
<li><NavLink to="/docs" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Docs</NavLink></li>
|
||||
<li><NavLink to="/wallet" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Wallet</NavLink></li>
|
||||
<li><NavLink to="/api" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>API</NavLink></li>
|
||||
<li><NavLink to="/faucet" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Faucet</NavLink></li>
|
||||
<li><NavLink to="/developers" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Developers</NavLink></li>
|
||||
<li><NavLink to="/grants" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Grants</NavLink></li>
|
||||
<li><NavLink to="/wiki" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Wiki</NavLink></li>
|
||||
<li><NavLink to="/forum" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Forum</NavLink></li>
|
||||
<li><NavLink to="/telemetry" className={linkStyle} style={({ isActive }) => isActive ? activeLinkStyle : undefined}>Telemetry</NavLink></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white p-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<p>© {new Date().getFullYear()} PezkuwiChain. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<div className="flex-grow overflow-auto pt-16"> {/* Add padding-top equal to header height */}
|
||||
<main className="container mx-auto p-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shield, Users, CheckCircle, XCircle, ExternalLink } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
getMultisigMemberInfo,
|
||||
calculateMultisigAddress,
|
||||
USDT_MULTISIG_CONFIG,
|
||||
formatMultisigAddress,
|
||||
} from '@pezkuwi/lib/multisig';
|
||||
import { getTikiDisplayName, getTikiEmoji } from '@pezkuwi/lib/tiki';
|
||||
|
||||
interface MultisigMembersProps {
|
||||
specificAddresses?: Record<string, string>;
|
||||
showMultisigAddress?: boolean;
|
||||
}
|
||||
|
||||
interface MultisigMember {
|
||||
address: string;
|
||||
displayName: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
isTiki: boolean;
|
||||
trustScore?: number;
|
||||
balance?: string;
|
||||
}
|
||||
|
||||
export const MultisigMembers: React.FC<MultisigMembersProps> = ({
|
||||
specificAddresses = {},
|
||||
showMultisigAddress = true,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const [members, setMembers] = useState<MultisigMember[]>([]);
|
||||
const [multisigAddress, setMultisigAddress] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
const fetchMembers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const memberInfo = await getMultisigMemberInfo(api, specificAddresses);
|
||||
setMembers(memberInfo);
|
||||
|
||||
// Calculate multisig address
|
||||
const addresses = memberInfo.map((m) => m.address);
|
||||
if (addresses.length > 0) {
|
||||
const multisig = calculateMultisigAddress(addresses);
|
||||
setMultisigAddress(multisig);
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching multisig members:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMembers();
|
||||
}, [api, isApiReady, specificAddresses]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">USDT Treasury Multisig</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{USDT_MULTISIG_CONFIG.threshold}/{members.length} Signatures Required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{members.length} Members
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Multisig Address */}
|
||||
{showMultisigAddress && multisigAddress && (
|
||||
<div className="mb-6 p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-2">Multisig Account</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm text-green-400 font-mono">{formatMultisigAddress(multisigAddress)}</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(multisigAddress)}
|
||||
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||
>
|
||||
Copy Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
<div className="space-y-3">
|
||||
{members.map((member, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 bg-gray-900/30 rounded-lg hover:bg-gray-900/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-800">
|
||||
<span className="text-xl">{getTikiEmoji(member.tiki)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">{member.role}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getTikiDisplayName(member.tiki)}
|
||||
</Badge>
|
||||
{member.isUnique && (
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
On-Chain
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<code className="text-xs text-gray-400 font-mono">
|
||||
{member.address.slice(0, 6)}...{member.address.slice(-4)}
|
||||
</code>
|
||||
<div className="flex items-center gap-2 mt-1 justify-end">
|
||||
{member.isUnique ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" title="Verified on-chain" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-yellow-500" title="Specified address" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert className="mt-6 bg-blue-900/20 border-blue-500">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-1">Security Features</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• {USDT_MULTISIG_CONFIG.threshold} out of {members.length} signatures required</li>
|
||||
<li>• {members.filter(m => m.isUnique).length} members verified on-chain via Tiki</li>
|
||||
<li>• No single person can control funds</li>
|
||||
<li>• All transactions visible on blockchain</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Explorer Link */}
|
||||
{multisigAddress && (
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href={`https://pezkuwichain.io/explorer/accounts/${multisigAddress}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
View on Explorer
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Activity, Wifi, WifiOff, Users, Box, TrendingUp } from 'lucide-react';
|
||||
|
||||
export const NetworkStats: React.FC = () => {
|
||||
const { api, isApiReady, error } = usePezkuwi();
|
||||
const [blockNumber, setBlockNumber] = useState<number>(0);
|
||||
const [blockHash, setBlockHash] = useState<string>('');
|
||||
const [finalizedBlock, setFinalizedBlock] = useState<number>(0);
|
||||
const [validatorCount, setValidatorCount] = useState<number>(0);
|
||||
const [collatorCount, setCollatorCount] = useState<number>(0);
|
||||
const [nominatorCount, setNominatorCount] = useState<number>(0);
|
||||
const [peers, setPeers] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
let unsubscribeNewHeads: () => void;
|
||||
let unsubscribeFinalizedHeads: () => void;
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
const subscribeToBlocks = async () => {
|
||||
try {
|
||||
// Subscribe to new blocks
|
||||
unsubscribeNewHeads = await api.rpc.chain.subscribeNewHeads((header) => {
|
||||
setBlockNumber(header.number.toNumber());
|
||||
setBlockHash(header.hash.toHex());
|
||||
});
|
||||
|
||||
// Subscribe to finalized blocks
|
||||
unsubscribeFinalizedHeads = await api.rpc.chain.subscribeFinalizedHeads((header) => {
|
||||
setFinalizedBlock(header.number.toNumber());
|
||||
});
|
||||
|
||||
// Update validator count, collator count, nominator count, and peer count every 3 seconds
|
||||
const updateNetworkStats = async () => {
|
||||
try {
|
||||
const health = await api.rpc.system.health();
|
||||
|
||||
// 1. Fetch Validators
|
||||
let vCount = 0;
|
||||
try {
|
||||
if (api.query.session?.validators) {
|
||||
const validators = await api.query.session.validators();
|
||||
if (validators) {
|
||||
vCount = validators.length;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch validators', err);
|
||||
}
|
||||
|
||||
// 2. Fetch Collators (Invulnerables)
|
||||
let cCount = 0;
|
||||
try {
|
||||
if (api.query.collatorSelection?.invulnerables) {
|
||||
const invulnerables = await api.query.collatorSelection.invulnerables();
|
||||
if (invulnerables) {
|
||||
cCount = invulnerables.length;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch collators', err);
|
||||
}
|
||||
|
||||
// 3. Count Nominators
|
||||
let nCount = 0;
|
||||
try {
|
||||
const nominators = await api.query.staking?.nominators.entries();
|
||||
if (nominators) {
|
||||
nCount = nominators.length;
|
||||
}
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Staking pallet not available, nominators = 0');
|
||||
}
|
||||
|
||||
setValidatorCount(vCount);
|
||||
setCollatorCount(cCount);
|
||||
setNominatorCount(nCount);
|
||||
setPeers(health.peers.toNumber());
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to update network stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
await updateNetworkStats();
|
||||
|
||||
// Update every 3 seconds
|
||||
intervalId = setInterval(updateNetworkStats, 3000);
|
||||
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to blocks:', err);
|
||||
}
|
||||
};
|
||||
|
||||
subscribeToBlocks();
|
||||
|
||||
return () => {
|
||||
if (unsubscribeNewHeads) unsubscribeNewHeads();
|
||||
if (unsubscribeFinalizedHeads) unsubscribeFinalizedHeads();
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [api, isApiReady]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-red-950/50 border-red-900">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-400">
|
||||
<WifiOff className="w-5 h-5" />
|
||||
Network Disconnected
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-300 text-sm">{error}</p>
|
||||
<p className="text-red-400 text-xs mt-2">
|
||||
Make sure your validator node is running at ws://127.0.0.1:9944
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isApiReady) {
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 animate-pulse" />
|
||||
Connecting to Network...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
{/* Connection Status */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-green-500" />
|
||||
Network Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className="bg-green-500/20 text-green-400 border-green-500/50">
|
||||
Connected
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">{peers} peers</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Latest Block */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<Box className="w-4 h-4 text-blue-500" />
|
||||
Latest Block
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
#{blockNumber.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-mono truncate">
|
||||
{blockHash.slice(0, 10)}...{blockHash.slice(-8)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Finalized Block */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-purple-500" />
|
||||
Finalized Block
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
#{finalizedBlock.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{blockNumber - finalizedBlock} blocks behind
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Validators */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-yellow-500" />
|
||||
Active Validators
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{validatorCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Validating blocks
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Collators */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-orange-500" />
|
||||
Active Collators
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{collatorCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Producing blocks
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nominators */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-cyan-500" />
|
||||
Active Nominators
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{nominatorCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Staking to validators
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Award, Crown, Shield, Users } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { getUserTikis } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import type { TikiInfo } from '@pezkuwi/lib/citizenship-workflow';
|
||||
|
||||
// Icon map for different Tiki roles
|
||||
const getTikiIcon = (role: string) => {
|
||||
const roleLower = role.toLowerCase();
|
||||
|
||||
if (roleLower.includes('welati') || roleLower.includes('citizen')) {
|
||||
return <Shield className="w-6 h-6 text-cyan-500" />;
|
||||
}
|
||||
if (roleLower.includes('serok') || roleLower.includes('leader') || roleLower.includes('chief')) {
|
||||
return <Crown className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
if (roleLower.includes('axa') || roleLower.includes('hekem') || roleLower.includes('elder') || roleLower.includes('wise')) {
|
||||
return <Award className="w-6 h-6 text-purple-500" />;
|
||||
}
|
||||
return <Users className="w-6 h-6 text-green-500" />;
|
||||
};
|
||||
|
||||
// Color scheme for different roles
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
const roleLower = role.toLowerCase();
|
||||
|
||||
if (roleLower.includes('welati') || roleLower.includes('citizen')) {
|
||||
return 'bg-cyan-500/10 text-cyan-500 border-cyan-500/30';
|
||||
}
|
||||
if (roleLower.includes('serok') || roleLower.includes('leader') || roleLower.includes('chief')) {
|
||||
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
|
||||
}
|
||||
if (roleLower.includes('axa') || roleLower.includes('hekem') || roleLower.includes('elder') || roleLower.includes('wise')) {
|
||||
return 'bg-purple-500/10 text-purple-500 border-purple-500/30';
|
||||
}
|
||||
return 'bg-green-500/10 text-green-500 border-green-500/30';
|
||||
};
|
||||
|
||||
export const NftList: React.FC = () => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [tikis, setTikis] = useState<TikiInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTikis = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const userTikis = await getUserTikis(api, selectedAccount.address);
|
||||
setTikis(userTikis);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching Tikis:', err);
|
||||
setError('Failed to load NFTs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTikis();
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
|
||||
<CardDescription>Your Tiki collection</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
|
||||
<CardDescription>Your Tiki collection</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (tikis.length === 0) {
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
|
||||
<CardDescription>Your Tiki collection</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<Award className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 mb-2">No NFTs yet</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Complete your citizenship application to receive your Welati Tiki NFT
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Award className="w-5 h-5" />
|
||||
Your NFTs (Tikiler)
|
||||
</CardTitle>
|
||||
<CardDescription>Your Tiki collection ({tikis.length} total)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{tikis.map((tiki, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 hover:border-cyan-500/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getTikiIcon(tiki.role)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-white text-sm">
|
||||
Tiki #{tiki.id}
|
||||
</h3>
|
||||
<Badge className={getRoleBadgeColor(tiki.role)}>
|
||||
{tiki.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Metadata if available */}
|
||||
{tiki.metadata && typeof tiki.metadata === 'object' && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{Object.entries(tiki.metadata).map(([key, value]) => (
|
||||
<div key={key} className="text-xs text-gray-400">
|
||||
<span className="font-medium">{key}:</span>{' '}
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Code, Database, TrendingUp, Gift, Award } from 'lucide-react';
|
||||
|
||||
interface Pallet {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
image: string;
|
||||
extrinsics: string[];
|
||||
storage: string[];
|
||||
}
|
||||
|
||||
const pallets: Pallet[] = [
|
||||
{
|
||||
id: 'pez-treasury',
|
||||
name: 'PEZ Treasury',
|
||||
icon: <Database className="w-6 h-6" />,
|
||||
description: 'Manages token distribution with 48-month synthetic halving mechanism',
|
||||
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315321470_3d093f4f.webp',
|
||||
extrinsics: ['initialize_treasury', 'release_monthly_funds', 'force_genesis_distribution'],
|
||||
storage: ['HalvingInfo', 'MonthlyReleases', 'TreasuryStartBlock']
|
||||
},
|
||||
{
|
||||
id: 'trust',
|
||||
name: 'Trust Score',
|
||||
icon: <TrendingUp className="w-6 h-6" />,
|
||||
description: 'Calculates weighted trust scores from multiple components',
|
||||
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315323202_06631fb8.webp',
|
||||
extrinsics: ['force_recalculate_trust_score', 'update_all_trust_scores', 'periodic_trust_score_update'],
|
||||
storage: ['TrustScores', 'TotalActiveTrustScore', 'BatchUpdateInProgress']
|
||||
},
|
||||
{
|
||||
id: 'staking-score',
|
||||
name: 'Staking Score',
|
||||
icon: <Award className="w-6 h-6" />,
|
||||
description: 'Time-based staking multipliers from 1.0x to 2.0x over 12 months',
|
||||
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315324943_84216eda.webp',
|
||||
extrinsics: ['start_score_tracking'],
|
||||
storage: ['StakingStartBlock']
|
||||
},
|
||||
{
|
||||
id: 'pez-rewards',
|
||||
name: 'PEZ Rewards',
|
||||
icon: <Gift className="w-6 h-6" />,
|
||||
description: 'Monthly epoch-based reward distribution system',
|
||||
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315326731_ca5f9a92.webp',
|
||||
extrinsics: ['initialize_rewards_system', 'record_trust_score', 'finalize_epoch', 'claim_reward'],
|
||||
storage: ['EpochInfo', 'EpochRewardPools', 'UserEpochScores', 'ClaimedRewards']
|
||||
}
|
||||
];
|
||||
|
||||
const PalletsGrid: React.FC = () => {
|
||||
const [selectedPallet, setSelectedPallet] = useState<Pallet | null>(null);
|
||||
|
||||
return (
|
||||
<section id="pallets" className="py-20 bg-gray-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
Core Runtime Pallets
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
Modular blockchain components powering PezkuwiChain's advanced features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{pallets.map((pallet) => (
|
||||
<div
|
||||
key={pallet.id}
|
||||
onClick={() => setSelectedPallet(pallet)}
|
||||
className="group relative bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 hover:border-purple-500/50 transition-all cursor-pointer overflow-hidden"
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-900/20 to-cyan-900/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={pallet.image}
|
||||
alt={pallet.name}
|
||||
className="w-20 h-20 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="p-2 bg-gradient-to-br from-purple-600/20 to-cyan-600/20 rounded-lg mr-3">
|
||||
{pallet.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white">{pallet.name}</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">{pallet.description}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-kurdish-yellow/30 text-kurdish-yellow text-xs rounded-full">
|
||||
{pallet.extrinsics.length} Extrinsics
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-cyan-900/30 text-cyan-400 text-xs rounded-full">
|
||||
{pallet.storage.length} Storage Items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{selectedPallet && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setSelectedPallet(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 rounded-xl border border-gray-700 max-w-2xl w-full max-h-[80vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-2xl font-bold text-white">{selectedPallet.name}</h3>
|
||||
<button
|
||||
onClick={() => setSelectedPallet(null)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-purple-400 mb-3">Extrinsics</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedPallet.extrinsics.map((ext) => (
|
||||
<div key={ext} className="flex items-center p-3 bg-gray-800/50 rounded-lg">
|
||||
<Code className="w-4 h-4 text-cyan-400 mr-3" />
|
||||
<code className="text-gray-300 font-mono text-sm">{ext}()</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-cyan-400 mb-3">Storage Items</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedPallet.storage.map((item) => (
|
||||
<div key={item} className="flex items-center p-3 bg-gray-800/50 rounded-lg">
|
||||
<Database className="w-4 h-4 text-purple-400 mr-3" />
|
||||
<code className="text-gray-300 font-mono text-sm">{item}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PalletsGrid;
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Wallet, Check, ExternalLink, Copy, LogOut } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export const PezkuwiWalletButton: React.FC = () => {
|
||||
const {
|
||||
accounts,
|
||||
selectedAccount,
|
||||
setSelectedAccount,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
error
|
||||
} = usePezkuwi();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleConnect = async () => {
|
||||
await connectWallet();
|
||||
if (accounts.length > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAccount = (account: typeof accounts[0]) => {
|
||||
setSelectedAccount(account);
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "Account Connected",
|
||||
description: `${account.meta.name} - ${formatAddress(account.address)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
disconnectWallet();
|
||||
toast({
|
||||
title: "Wallet Disconnected",
|
||||
description: "Your wallet has been disconnected",
|
||||
});
|
||||
};
|
||||
|
||||
const formatAddress = (address: string) => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const copyAddress = () => {
|
||||
if (selectedAccount) {
|
||||
navigator.clipboard.writeText(selectedAccount.address);
|
||||
toast({
|
||||
title: "Address Copied",
|
||||
description: "Address copied to clipboard",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedAccount) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-green-500/20 border-green-500/50 text-green-400 hover:bg-green-500/30"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Wallet className="w-4 h-4 mr-2" />
|
||||
{selectedAccount.meta.name || 'Account'}
|
||||
<Badge className="ml-2 bg-green-500/30 text-green-300 border-0">
|
||||
{formatAddress(selectedAccount.address)}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDisconnect}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Account Details</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Your connected Pezkuwi account
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-800/50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400 mb-1">Account Name</div>
|
||||
<div className="text-white font-medium">
|
||||
{selectedAccount.meta.name || 'Unnamed Account'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400 mb-1">Address</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-white text-sm font-mono">
|
||||
{selectedAccount.address}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyAddress}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400 mb-1">Source</div>
|
||||
<div className="text-white">
|
||||
{selectedAccount.meta.source || 'pezkuwi'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-2">Switch Account</div>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
key={account.address}
|
||||
onClick={() => handleSelectAccount(account)}
|
||||
className={`w-full p-3 rounded-lg border transition-all flex items-center justify-between ${
|
||||
account.address === selectedAccount.address
|
||||
? 'bg-green-500/20 border-green-500/50'
|
||||
: 'bg-gray-800/50 border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-white font-medium">
|
||||
{account.meta.name || 'Unnamed'}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs font-mono">
|
||||
{formatAddress(account.address)}
|
||||
</div>
|
||||
</div>
|
||||
{account.address === selectedAccount.address && (
|
||||
<Check className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
className="bg-gradient-to-r from-green-600 to-yellow-400 hover:from-green-700 hover:to-yellow-500 text-white"
|
||||
>
|
||||
<Wallet className="w-4 h-4 mr-2" />
|
||||
Connect Wallet
|
||||
</Button>
|
||||
|
||||
{error && error.includes('extension') && (
|
||||
<Dialog open={!!error} onOpenChange={() => {}}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Install Pezkuwi.js Extension</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
You need the Pezkuwi.js browser extension to connect your wallet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-300">
|
||||
The Pezkuwi.js extension allows you to manage your accounts and sign transactions securely.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="https://js.pezkuwichain.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1"
|
||||
>
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Install Extension
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
After installing, refresh this page and click "Connect Wallet" again.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Dialog open={isOpen && accounts.length > 0} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Select Account</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Choose an account to connect
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
key={account.address}
|
||||
onClick={() => handleSelectAccount(account)}
|
||||
className="w-full p-4 rounded-lg border border-gray-700 bg-gray-800/50 hover:border-green-500/50 hover:bg-gray-800 transition-all text-left"
|
||||
>
|
||||
<div className="text-white font-medium mb-1">
|
||||
{account.meta.name || 'Unnamed Account'}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm font-mono">
|
||||
{account.address}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,619 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
|
||||
import { AddLiquidityModal } from '@/components/AddLiquidityModal';
|
||||
import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal';
|
||||
|
||||
// Helper function to convert asset IDs to user-friendly display names
|
||||
// Users should only see HEZ, PEZ, USDT - wrapped tokens are backend details
|
||||
const getDisplayTokenName = (assetId: number): string => {
|
||||
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
|
||||
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
|
||||
if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT';
|
||||
return getAssetSymbol(assetId); // Fallback for other assets
|
||||
};
|
||||
|
||||
// Helper function to get decimals for each asset
|
||||
const getAssetDecimals = (assetId: number): number => {
|
||||
if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals
|
||||
return 12; // wHEZ, PEZ have 12 decimals
|
||||
};
|
||||
|
||||
interface PoolData {
|
||||
asset0: number;
|
||||
asset1: number;
|
||||
reserve0: number;
|
||||
reserve1: number;
|
||||
lpTokenId: number;
|
||||
poolAccount: string;
|
||||
}
|
||||
|
||||
interface LPPosition {
|
||||
lpTokenBalance: number;
|
||||
share: number; // Percentage of pool
|
||||
asset0Amount: number;
|
||||
asset1Amount: number;
|
||||
}
|
||||
|
||||
const PoolDashboard = () => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
|
||||
const [poolData, setPoolData] = useState<PoolData | null>(null);
|
||||
const [lpPosition, setLPPosition] = useState<LPPosition | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false);
|
||||
const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false);
|
||||
|
||||
// Pool selection state
|
||||
const [availablePools, setAvailablePools] = useState<Array<[number, number]>>([]);
|
||||
const [selectedPool, setSelectedPool] = useState<string>('0-1'); // Default: wHEZ/PEZ
|
||||
|
||||
// Discover available pools
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
const discoverPools = async () => {
|
||||
try {
|
||||
// Check all possible pool combinations in both directions
|
||||
// Pools can be stored as [A,B] or [B,A] depending on creation order
|
||||
// Note: .env sets WUSDT to Asset ID based on VITE_ASSET_WUSDT
|
||||
const possiblePools: Array<[number, number]> = [
|
||||
[ASSET_IDS.WHEZ, ASSET_IDS.PEZ], // wHEZ(0) / PEZ(1) -> Shows as HEZ-PEZ
|
||||
[ASSET_IDS.PEZ, ASSET_IDS.WHEZ], // PEZ(1) / wHEZ(0) -> Shows as HEZ-PEZ (reverse)
|
||||
[ASSET_IDS.WHEZ, ASSET_IDS.WUSDT], // wHEZ(0) / wUSDT -> Shows as HEZ-USDT
|
||||
[ASSET_IDS.WUSDT, ASSET_IDS.WHEZ], // wUSDT / wHEZ(0) -> Shows as HEZ-USDT (reverse)
|
||||
[ASSET_IDS.PEZ, ASSET_IDS.WUSDT], // PEZ(1) / wUSDT -> Shows as PEZ-USDT
|
||||
[ASSET_IDS.WUSDT, ASSET_IDS.PEZ], // wUSDT / PEZ(1) -> Shows as PEZ-USDT (reverse)
|
||||
];
|
||||
|
||||
const existingPools: Array<[number, number]> = [];
|
||||
|
||||
for (const [asset0, asset1] of possiblePools) {
|
||||
try {
|
||||
const poolInfo = await api.query.assetConversion.pools([asset0, asset1]);
|
||||
if (poolInfo.isSome) {
|
||||
existingPools.push([asset0, asset1]);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`✅ Found pool: ${asset0}-${asset1}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip pools that error out (likely don't exist)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`❌ Pool ${asset0}-${asset1} not found or error:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('📊 Total pools found:', existingPools.length, existingPools);
|
||||
}
|
||||
|
||||
setAvailablePools(existingPools);
|
||||
|
||||
// Set default pool to first available if current selection doesn't exist
|
||||
if (existingPools.length > 0) {
|
||||
const currentPoolKey = selectedPool;
|
||||
const poolExists = existingPools.some(
|
||||
([a0, a1]) => `${a0}-${a1}` === currentPoolKey
|
||||
);
|
||||
if (!poolExists) {
|
||||
const [firstAsset0, firstAsset1] = existingPools[0];
|
||||
setSelectedPool(`${firstAsset0}-${firstAsset1}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error discovering pools:', err);
|
||||
}
|
||||
};
|
||||
|
||||
discoverPools();
|
||||
}, [api, isApiReady, selectedPool]);
|
||||
|
||||
|
||||
// Fetch pool data
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedPool) return;
|
||||
|
||||
const fetchPoolData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Parse selected pool (e.g., "1-2" -> [1, 2])
|
||||
const [asset1Str, asset2Str] = selectedPool.split('-');
|
||||
const asset1 = parseInt(asset1Str);
|
||||
const asset2 = parseInt(asset2Str);
|
||||
const poolId = [asset1, asset2];
|
||||
|
||||
const poolInfo = await api.query.assetConversion.pools(poolId);
|
||||
|
||||
if (poolInfo.isSome) {
|
||||
const lpTokenData = poolInfo.unwrap().toJSON() as Record<string, unknown>;
|
||||
const lpTokenId = lpTokenData.lpToken;
|
||||
|
||||
// Derive pool account using AccountIdConverter
|
||||
const { stringToU8a } = await import('@pezkuwi/util');
|
||||
const { blake2AsU8a } = await import('@pezkuwi/util-crypto');
|
||||
|
||||
// PalletId for AssetConversion: "py/ascon" (8 bytes)
|
||||
const PALLET_ID = stringToU8a('py/ascon');
|
||||
|
||||
// Create PoolId tuple (u32, u32)
|
||||
const poolIdType = api.createType('(u32, u32)', [asset1, asset2]);
|
||||
|
||||
// Create (PalletId, PoolId) tuple: ([u8; 8], (u32, u32))
|
||||
const palletIdType = api.createType('[u8; 8]', PALLET_ID);
|
||||
const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolIdType]);
|
||||
|
||||
// Hash the SCALE-encoded tuple
|
||||
const accountHash = blake2AsU8a(fullTuple.toU8a(), 256);
|
||||
const poolAccountId = api.createType('AccountId32', accountHash);
|
||||
const poolAccount = poolAccountId.toString();
|
||||
|
||||
// Get reserves
|
||||
const asset0BalanceData = await api.query.assets.account(asset1, poolAccountId);
|
||||
const asset1BalanceData = await api.query.assets.account(asset2, poolAccountId);
|
||||
|
||||
let reserve0 = 0;
|
||||
let reserve1 = 0;
|
||||
|
||||
// Use dynamic decimals for each asset
|
||||
const asset1Decimals = getAssetDecimals(asset1);
|
||||
const asset2Decimals = getAssetDecimals(asset2);
|
||||
|
||||
if (asset0BalanceData.isSome) {
|
||||
const asset0Data = asset0BalanceData.unwrap().toJSON() as Record<string, unknown>;
|
||||
reserve0 = Number(asset0Data.balance) / Math.pow(10, asset1Decimals);
|
||||
}
|
||||
|
||||
if (asset1BalanceData.isSome) {
|
||||
const asset1Data = asset1BalanceData.unwrap().toJSON() as Record<string, unknown>;
|
||||
reserve1 = Number(asset1Data.balance) / Math.pow(10, asset2Decimals);
|
||||
}
|
||||
|
||||
setPoolData({
|
||||
asset0: asset1,
|
||||
asset1: asset2,
|
||||
reserve0,
|
||||
reserve1,
|
||||
lpTokenId,
|
||||
poolAccount,
|
||||
});
|
||||
|
||||
// Get user's LP position if account connected
|
||||
if (selectedAccount) {
|
||||
await fetchLPPosition(lpTokenId, reserve0, reserve1);
|
||||
}
|
||||
} else {
|
||||
setError('Pool not found');
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching pool data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch pool data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLPPosition = async (lpTokenId: number, reserve0: number, reserve1: number) => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
// Query user's LP token balance
|
||||
const lpBalance = await api.query.poolAssets.account(lpTokenId, selectedAccount.address);
|
||||
|
||||
if (lpBalance.isSome) {
|
||||
const lpData = lpBalance.unwrap().toJSON() as Record<string, unknown>;
|
||||
const userLpBalance = Number(lpData.balance) / 1e12;
|
||||
|
||||
// Query total LP supply
|
||||
const lpAssetData = await api.query.poolAssets.asset(lpTokenId);
|
||||
|
||||
if (lpAssetData.isSome) {
|
||||
const assetInfo = lpAssetData.unwrap().toJSON() as Record<string, unknown>;
|
||||
const totalSupply = Number(assetInfo.supply) / 1e12;
|
||||
|
||||
// Calculate user's share
|
||||
const sharePercentage = (userLpBalance / totalSupply) * 100;
|
||||
|
||||
// Calculate user's actual token amounts
|
||||
const asset0Amount = (sharePercentage / 100) * reserve0;
|
||||
const asset1Amount = (sharePercentage / 100) * reserve1;
|
||||
|
||||
setLPPosition({
|
||||
lpTokenBalance: userLpBalance,
|
||||
share: sharePercentage,
|
||||
asset0Amount,
|
||||
asset1Amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching LP position:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPoolData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchPoolData, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, selectedAccount, selectedPool]);
|
||||
|
||||
// Calculate metrics
|
||||
const constantProduct = poolData ? poolData.reserve0 * poolData.reserve1 : 0;
|
||||
const currentPrice = poolData ? poolData.reserve1 / poolData.reserve0 : 0;
|
||||
const totalLiquidityUSD = poolData ? poolData.reserve0 * 2 : 0; // Simplified: assumes 1:1 USD peg
|
||||
|
||||
// APR calculation (simplified - would need 24h volume data)
|
||||
const estimateAPR = () => {
|
||||
if (!poolData) return 0;
|
||||
|
||||
// Estimate based on pool size and typical volume
|
||||
// This is a simplified calculation
|
||||
// Real APR = (24h fees × 365) / TVL
|
||||
const dailyVolumeEstimate = totalLiquidityUSD * 0.1; // Assume 10% daily turnover
|
||||
const dailyFees = dailyVolumeEstimate * 0.03; // 3% fee
|
||||
const annualFees = dailyFees * 365;
|
||||
const apr = (annualFees / totalLiquidityUSD) * 100;
|
||||
|
||||
return apr;
|
||||
};
|
||||
|
||||
// Impermanent loss calculator
|
||||
const calculateImpermanentLoss = (priceChange: number) => {
|
||||
// IL formula: 2 * sqrt(price_ratio) / (1 + price_ratio) - 1
|
||||
const priceRatio = 1 + priceChange / 100;
|
||||
const il = ((2 * Math.sqrt(priceRatio)) / (1 + priceRatio) - 1) * 100;
|
||||
return il;
|
||||
};
|
||||
|
||||
if (isLoading && !poolData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-400">Loading pool data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!poolData) {
|
||||
return (
|
||||
<Alert className="bg-yellow-900/20 border-yellow-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>No pool data available</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Get asset symbols for the selected pool (using display names)
|
||||
const asset0Symbol = poolData ? getDisplayTokenName(poolData.asset0) : '';
|
||||
const asset1Symbol = poolData ? getDisplayTokenName(poolData.asset1) : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pool Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-1">Pool Dashboards</h3>
|
||||
<Select value={selectedPool} onValueChange={setSelectedPool}>
|
||||
<SelectTrigger className="w-[240px] bg-gray-800/50 border-gray-700">
|
||||
<SelectValue placeholder="Select pool" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availablePools.map(([asset0, asset1]) => {
|
||||
const symbol0 = getDisplayTokenName(asset0);
|
||||
const symbol1 = getDisplayTokenName(asset1);
|
||||
return (
|
||||
<SelectItem key={`${asset0}-${asset1}`} value={`${asset0}-${asset1}`}>
|
||||
{symbol0}/{symbol1}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Pool Dashboard Title */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Droplet className="h-6 w-6 text-blue-400" />
|
||||
{asset0Symbol}/{asset1Symbol} Pool Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">Monitor liquidity pool metrics and your position</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Liquidity */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Total Liquidity</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
${totalLiquidityUSD.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{poolData.reserve0.toLocaleString()} {asset0Symbol} + {poolData.reserve1.toLocaleString()} {asset1Symbol}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Current Price */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{asset0Symbol} Price</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
${currentPrice.toFixed(4)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
1 {asset1Symbol} = {(1 / currentPrice).toFixed(4)} {asset0Symbol}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* APR */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Estimated APR</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
{estimateAPR().toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
From swap fees
|
||||
</p>
|
||||
</div>
|
||||
<Percent className="h-8 w-8 text-yellow-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Constant Product */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Constant (k)</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
{(constantProduct / 1e9).toFixed(1)}B
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
x × y = k
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 className="h-8 w-8 text-purple-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="reserves" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-gray-800">
|
||||
<TabsTrigger value="reserves">Reserves</TabsTrigger>
|
||||
<TabsTrigger value="position">Your Position</TabsTrigger>
|
||||
<TabsTrigger value="calculator">IL Calculator</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Reserves Tab */}
|
||||
<TabsContent value="reserves" className="space-y-4">
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Pool Reserves</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{asset0Symbol} Reserve</p>
|
||||
<p className="text-2xl font-bold text-white">{poolData.reserve0.toLocaleString('en-US', { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<Badge variant="outline">Asset 1</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{asset1Symbol} Reserve</p>
|
||||
<p className="text-2xl font-bold text-white">{poolData.reserve1.toLocaleString('en-US', { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<Badge variant="outline">Asset 2</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-900/20 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-blue-400 mt-0.5" />
|
||||
<div className="text-sm text-gray-300">
|
||||
<p className="font-semibold text-blue-400 mb-1">AMM Formula</p>
|
||||
<p>Pool maintains constant product: x × y = k</p>
|
||||
<p className="mt-2 font-mono text-xs">
|
||||
{poolData.reserve0.toFixed(2)} × {poolData.reserve1.toFixed(2)} = {constantProduct.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Your Position Tab */}
|
||||
<TabsContent value="position" className="space-y-4">
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Your Liquidity Position</h3>
|
||||
|
||||
{!selectedAccount ? (
|
||||
<Alert className="bg-yellow-900/20 border-yellow-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>Connect wallet to view your position</AlertDescription>
|
||||
</Alert>
|
||||
) : !lpPosition ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<Droplet className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No liquidity position found</p>
|
||||
<Button
|
||||
onClick={() => setIsAddLiquidityModalOpen(true)}
|
||||
className="mt-4 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700"
|
||||
>
|
||||
Add Liquidity
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400">LP Tokens</p>
|
||||
<p className="text-xl font-bold text-white">{lpPosition.lpTokenBalance.toFixed(4)}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400">Pool Share</p>
|
||||
<p className="text-xl font-bold text-white">{lpPosition.share.toFixed(4)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">Your Position Value</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">{asset0Symbol}:</span>
|
||||
<span className="text-white font-semibold">{lpPosition.asset0Amount.toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">{asset1Symbol}:</span>
|
||||
<span className="text-white font-semibold">{lpPosition.asset1Amount.toFixed(4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">Estimated Earnings (APR {estimateAPR().toFixed(2)}%)</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">Daily:</span>
|
||||
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 365 / 100).toFixed(4)} {asset0Symbol}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">Monthly:</span>
|
||||
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 12 / 100).toFixed(4)} {asset0Symbol}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">Yearly:</span>
|
||||
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 100).toFixed(4)} {asset0Symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<Button
|
||||
onClick={() => setIsAddLiquidityModalOpen(true)}
|
||||
className="bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700"
|
||||
>
|
||||
Add More
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsRemoveLiquidityModalOpen(true)}
|
||||
variant="outline"
|
||||
className="border-red-600 text-red-400 hover:bg-red-900/20"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Impermanent Loss Calculator Tab */}
|
||||
<TabsContent value="calculator" className="space-y-4">
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Impermanent Loss Calculator</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-3">If {asset0Symbol} price changes by:</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[10, 25, 50, 100, 200].map((change) => {
|
||||
const il = calculateImpermanentLoss(change);
|
||||
return (
|
||||
<div key={change} className="flex justify-between items-center py-2 border-b border-gray-700 last:border-0">
|
||||
<span className="text-gray-300">+{change}%</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={il < -1 ? 'border-red-500 text-red-400' : 'border-yellow-500 text-yellow-400'}
|
||||
>
|
||||
{il.toFixed(2)}% Loss
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-orange-900/20 border-orange-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-1">What is Impermanent Loss?</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
Impermanent loss occurs when the price ratio of tokens in the pool changes.
|
||||
The larger the price change, the greater the loss compared to simply holding the tokens.
|
||||
Fees earned from swaps can offset this loss over time.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Modals */}
|
||||
<AddLiquidityModal
|
||||
isOpen={isAddLiquidityModalOpen}
|
||||
onClose={() => setIsAddLiquidityModalOpen(false)}
|
||||
asset0={poolData?.asset0}
|
||||
asset1={poolData?.asset1}
|
||||
/>
|
||||
|
||||
{lpPosition && poolData && (
|
||||
<RemoveLiquidityModal
|
||||
isOpen={isRemoveLiquidityModalOpen}
|
||||
onClose={() => setIsRemoveLiquidityModalOpen(false)}
|
||||
lpPosition={lpPosition}
|
||||
lpTokenId={poolData.lpTokenId}
|
||||
asset0={poolData.asset0}
|
||||
asset1={poolData.asset1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PoolDashboard;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Loader2, Wallet } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requireAdmin = false
|
||||
}) => {
|
||||
const { user, loading, isAdmin } = useAuth();
|
||||
const { selectedAccount, connectWallet } = usePezkuwi();
|
||||
const [walletRestoreChecked, setWalletRestoreChecked] = useState(false);
|
||||
const [forceUpdate, setForceUpdate] = useState(0);
|
||||
|
||||
// Listen for wallet changes
|
||||
useEffect(() => {
|
||||
const handleWalletChange = () => {
|
||||
setForceUpdate(prev => prev + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('walletChanged', handleWalletChange);
|
||||
return () => window.removeEventListener('walletChanged', handleWalletChange);
|
||||
}, []);
|
||||
|
||||
// Wait for wallet restoration (max 3 seconds)
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setWalletRestoreChecked(true);
|
||||
}, 3000);
|
||||
|
||||
// If wallet restored earlier, clear timeout
|
||||
if (selectedAccount) {
|
||||
setWalletRestoreChecked(true);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [selectedAccount, forceUpdate]);
|
||||
|
||||
// Show loading while:
|
||||
// 1. Auth is loading, OR
|
||||
// 2. Wallet restoration not checked yet
|
||||
if (loading || !walletRestoreChecked) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500 mx-auto mb-4" />
|
||||
<p className="text-gray-400">
|
||||
{!walletRestoreChecked ? 'Restoring wallet connection...' : 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For admin routes, require wallet connection
|
||||
if (requireAdmin && !selectedAccount) {
|
||||
const handleConnect = async () => {
|
||||
await connectWallet();
|
||||
// Event is automatically dispatched by handleSetSelectedAccount wrapper
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md">
|
||||
<Wallet className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Connect Your Wallet</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Admin panel requires wallet authentication. Please connect your wallet to continue.
|
||||
</p>
|
||||
<Button onClick={handleConnect} size="lg" className="bg-green-600 hover:bg-green-700">
|
||||
<Wallet className="mr-2 h-5 w-5" />
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-red-500 text-6xl mb-4">⛔</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Access Denied</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Your wallet ({selectedAccount?.address.slice(0, 8)}...) does not have admin privileges.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Only founder and commission members can access the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, CheckCircle, QrCode } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface ReceiveModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ReceiveModal: React.FC<ReceiveModalProps> = ({ isOpen, onClose }) => {
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedAccount && isOpen) {
|
||||
// Generate QR code
|
||||
QRCode.toDataURL(selectedAccount.address, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#ffffff',
|
||||
light: '#0f172a'
|
||||
}
|
||||
}).then(setQrCodeDataUrl).catch((err) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('QR code generation failed:', err);
|
||||
});
|
||||
}
|
||||
}, [selectedAccount, isOpen]);
|
||||
|
||||
const handleCopyAddress = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedAccount.address);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: "Address Copied!",
|
||||
description: "Your wallet address has been copied to clipboard",
|
||||
});
|
||||
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast({
|
||||
title: "Copy Failed",
|
||||
description: "Failed to copy address to clipboard",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Receive Tokens</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Share this address to receive HEZ, PEZ, and other tokens
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* QR Code */}
|
||||
<div className="bg-white rounded-lg p-4 mx-auto w-fit">
|
||||
{qrCodeDataUrl ? (
|
||||
<img src={qrCodeDataUrl} alt="QR Code" className="w-64 h-64" />
|
||||
) : (
|
||||
<div className="w-64 h-64 flex items-center justify-center bg-gray-200">
|
||||
<QrCode className="w-16 h-16 text-gray-400 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account Name */}
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-400 mb-1">Account Name</div>
|
||||
<div className="text-xl font-semibold text-white">
|
||||
{selectedAccount.meta.name || 'Unnamed Account'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="bg-gray-800/50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400 mb-2">Wallet Address</div>
|
||||
<div className="bg-gray-900 rounded p-3 mb-3">
|
||||
<div className="text-white font-mono text-sm break-all">
|
||||
{selectedAccount.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCopyAddress}
|
||||
className="w-full bg-gradient-to-r from-green-600 to-yellow-400 hover:from-green-700 hover:to-yellow-500"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Address
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||
<p className="text-yellow-400 text-xs">
|
||||
<strong>Important:</strong> Only send PezkuwiChain compatible tokens to this address. Sending other tokens may result in permanent loss.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Minus, AlertCircle, Info } from 'lucide-react';
|
||||
import { web3FromAddress } from '@pezkuwi/extension-dapp';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
|
||||
|
||||
// Helper to get display name for tokens (users see HEZ not wHEZ, USDT not wUSDT)
|
||||
const getDisplayTokenName = (assetId: number): string => {
|
||||
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
|
||||
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
|
||||
if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT';
|
||||
return getAssetSymbol(assetId);
|
||||
};
|
||||
|
||||
// Helper to get decimals for each asset
|
||||
const getAssetDecimals = (assetId: number): number => {
|
||||
if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals
|
||||
return 12; // wHEZ, PEZ have 12 decimals
|
||||
};
|
||||
|
||||
interface RemoveLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
lpPosition: {
|
||||
lpTokenBalance: number;
|
||||
share: number;
|
||||
asset0Amount: number;
|
||||
asset1Amount: number;
|
||||
};
|
||||
lpTokenId: number;
|
||||
asset0: number; // First asset ID in the pool
|
||||
asset1: number; // Second asset ID in the pool
|
||||
}
|
||||
|
||||
export const RemoveLiquidityModal: React.FC<RemoveLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
lpPosition,
|
||||
asset0,
|
||||
asset1,
|
||||
}) => {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const { refreshBalances } = useWallet();
|
||||
|
||||
const [percentage, setPercentage] = useState(100);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [minBalance0, setMinBalance0] = useState<number>(0);
|
||||
const [minBalance1, setMinBalance1] = useState<number>(0);
|
||||
const [maxRemovablePercentage, setMaxRemovablePercentage] = useState<number>(100);
|
||||
|
||||
// Fetch minimum balances for both assets
|
||||
useEffect(() => {
|
||||
if (!api || !isOpen) return;
|
||||
|
||||
const fetchMinBalances = async () => {
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`🔍 Fetching minBalances for pool: asset0=${asset0} (${getDisplayTokenName(asset0)}), asset1=${asset1} (${getDisplayTokenName(asset1)})`);
|
||||
|
||||
// For wHEZ (asset ID 0), we need to fetch from assets pallet
|
||||
// For native HEZ, we would need existentialDeposit from balances
|
||||
// But in our pools, we only use wHEZ, wUSDT, PEZ (all wrapped assets)
|
||||
|
||||
if (asset0 === ASSET_IDS.WHEZ || asset0 === 0) {
|
||||
// wHEZ is an asset in the assets pallet
|
||||
const assetDetails0 = await api.query.assets.asset(ASSET_IDS.WHEZ);
|
||||
if (assetDetails0.isSome) {
|
||||
const details0 = assetDetails0.unwrap().toJSON() as Record<string, unknown>;
|
||||
const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0));
|
||||
setMinBalance0(min0);
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset0)} minBalance: ${min0}`);
|
||||
}
|
||||
} else {
|
||||
// Other assets (PEZ, wUSDT, etc.)
|
||||
const assetDetails0 = await api.query.assets.asset(asset0);
|
||||
if (assetDetails0.isSome) {
|
||||
const details0 = assetDetails0.unwrap().toJSON() as Record<string, unknown>;
|
||||
const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0));
|
||||
setMinBalance0(min0);
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset0)} minBalance: ${min0}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (asset1 === ASSET_IDS.WHEZ || asset1 === 0) {
|
||||
// wHEZ is an asset in the assets pallet
|
||||
const assetDetails1 = await api.query.assets.asset(ASSET_IDS.WHEZ);
|
||||
if (assetDetails1.isSome) {
|
||||
const details1 = assetDetails1.unwrap().toJSON() as Record<string, unknown>;
|
||||
const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1));
|
||||
setMinBalance1(min1);
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset1)} minBalance: ${min1}`);
|
||||
}
|
||||
} else {
|
||||
// Other assets (PEZ, wUSDT, etc.)
|
||||
const assetDetails1 = await api.query.assets.asset(asset1);
|
||||
if (assetDetails1.isSome) {
|
||||
const details1 = assetDetails1.unwrap().toJSON() as Record<string, unknown>;
|
||||
const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1));
|
||||
setMinBalance1(min1);
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset1)} minBalance: ${min1}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching minBalances:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMinBalances();
|
||||
}, [api, isOpen, asset0, asset1]);
|
||||
|
||||
// Calculate maximum removable percentage based on minBalance requirements
|
||||
useEffect(() => {
|
||||
if (minBalance0 === 0 || minBalance1 === 0) return;
|
||||
|
||||
// Calculate what percentage would leave exactly minBalance
|
||||
const maxPercent0 = ((lpPosition.asset0Amount - minBalance0) / lpPosition.asset0Amount) * 100;
|
||||
const maxPercent1 = ((lpPosition.asset1Amount - minBalance1) / lpPosition.asset1Amount) * 100;
|
||||
|
||||
// Take the lower of the two (most restrictive)
|
||||
const maxPercent = Math.min(maxPercent0, maxPercent1, 100);
|
||||
|
||||
// Round down to be safe
|
||||
const safeMaxPercent = Math.floor(maxPercent * 10) / 10;
|
||||
|
||||
setMaxRemovablePercentage(safeMaxPercent > 0 ? safeMaxPercent : 99);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`🔒 Max removable: ${safeMaxPercent}% (asset0: ${maxPercent0.toFixed(2)}%, asset1: ${maxPercent1.toFixed(2)}%)`);
|
||||
}, [minBalance0, minBalance1, lpPosition.asset0Amount, lpPosition.asset1Amount]);
|
||||
|
||||
const handleRemoveLiquidity = async () => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the signer from the extension
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Get decimals for each asset
|
||||
const asset0Decimals = getAssetDecimals(asset0);
|
||||
const asset1Decimals = getAssetDecimals(asset1);
|
||||
|
||||
// Calculate LP tokens to remove
|
||||
const lpToRemove = (lpPosition.lpTokenBalance * percentage) / 100;
|
||||
const lpToRemoveBN = BigInt(Math.floor(lpToRemove * 1e12));
|
||||
|
||||
// Calculate expected token amounts (with 95% slippage tolerance)
|
||||
const expectedAsset0BN = BigInt(Math.floor((lpPosition.asset0Amount * percentage) / 100 * Math.pow(10, asset0Decimals)));
|
||||
const expectedAsset1BN = BigInt(Math.floor((lpPosition.asset1Amount * percentage) / 100 * Math.pow(10, asset1Decimals)));
|
||||
|
||||
const minAsset0BN = (expectedAsset0BN * BigInt(95)) / BigInt(100);
|
||||
const minAsset1BN = (expectedAsset1BN * BigInt(95)) / BigInt(100);
|
||||
|
||||
// Remove liquidity transaction
|
||||
const removeLiquidityTx = api.tx.assetConversion.removeLiquidity(
|
||||
asset0,
|
||||
asset1,
|
||||
lpToRemoveBN.toString(),
|
||||
minAsset0BN.toString(),
|
||||
minAsset1BN.toString(),
|
||||
selectedAccount.address
|
||||
);
|
||||
|
||||
// Check if we need to unwrap wHEZ back to HEZ
|
||||
const hasWHEZ = asset0 === ASSET_IDS.WHEZ || asset1 === ASSET_IDS.WHEZ;
|
||||
let tx;
|
||||
|
||||
if (hasWHEZ) {
|
||||
// Unwrap wHEZ back to HEZ
|
||||
const whezAmount = asset0 === ASSET_IDS.WHEZ ? minAsset0BN : minAsset1BN;
|
||||
const unwrapTx = api.tx.tokenWrapper.unwrap(whezAmount.toString());
|
||||
// Batch transactions: removeLiquidity + unwrap
|
||||
tx = api.tx.utility.batchAll([removeLiquidityTx, unwrapTx]);
|
||||
} else {
|
||||
// No unwrap needed for pools without wHEZ
|
||||
tx = removeLiquidityTx;
|
||||
}
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, events }) => {
|
||||
if (status.isInBlock) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction in block');
|
||||
} else if (status.isFinalized) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction finalized');
|
||||
|
||||
// Check for errors
|
||||
const hasError = events.some(({ event }) =>
|
||||
api.events.system.ExtrinsicFailed.is(event)
|
||||
);
|
||||
|
||||
if (hasError) {
|
||||
setError('Transaction failed');
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setIsLoading(false);
|
||||
refreshBalances();
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error removing liquidity:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove liquidity');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Get display names for the assets
|
||||
const asset0Name = getDisplayTokenName(asset0);
|
||||
const asset1Name = getDisplayTokenName(asset1);
|
||||
|
||||
const asset0ToReceive = (lpPosition.asset0Amount * percentage) / 100;
|
||||
const asset1ToReceive = (lpPosition.asset1Amount * percentage) / 100;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6 border border-gray-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Remove Liquidity</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert className="mb-4 bg-red-900/20 border-red-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="mb-4 bg-green-900/20 border-green-500">
|
||||
<AlertDescription>Liquidity removed successfully!</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert className="mb-4 bg-blue-900/20 border-blue-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Remove your liquidity to receive back your tokens.{' '}
|
||||
{(asset0 === ASSET_IDS.WHEZ || asset1 === ASSET_IDS.WHEZ) && 'wHEZ will be automatically unwrapped to HEZ.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{maxRemovablePercentage < 100 && (
|
||||
<Alert className="mb-4 bg-yellow-900/20 border-yellow-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Maximum removable: {maxRemovablePercentage.toFixed(1)}% - Pool must maintain minimum balance of {minBalance0.toFixed(6)} {asset0Name} and {minBalance1.toFixed(6)} {asset1Name}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Percentage Selector */}
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-300">Amount to Remove</label>
|
||||
<span className="text-2xl font-bold text-white">{percentage}%</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max={maxRemovablePercentage}
|
||||
value={Math.min(percentage, maxRemovablePercentage)}
|
||||
onChange={(e) => setPercentage(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
{[25, 50, 75, 100].map((p) => {
|
||||
const effectiveP = p === 100 ? Math.floor(maxRemovablePercentage) : p;
|
||||
const isDisabled = p > maxRemovablePercentage;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPercentage(Math.min(effectiveP, maxRemovablePercentage))}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
percentage === effectiveP
|
||||
? 'bg-blue-600 text-white'
|
||||
: isDisabled
|
||||
? 'bg-gray-800 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
disabled={isLoading || isDisabled}
|
||||
>
|
||||
{p === 100 ? 'MAX' : `${p}%`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* You Will Receive */}
|
||||
<div className="bg-gray-800 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">You Will Receive</h3>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">{asset0Name}</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{asset0ToReceive.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<Minus className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">{asset1Name}</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{asset1ToReceive.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<Minus className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LP Token Info */}
|
||||
<div className="bg-gray-800 rounded-lg p-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>LP Tokens to Burn</span>
|
||||
<span>{((lpPosition.lpTokenBalance * percentage) / 100).toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Remaining LP Tokens</span>
|
||||
<span>
|
||||
{((lpPosition.lpTokenBalance * (100 - percentage)) / 100).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Remaining {asset0Name}</span>
|
||||
<span className={asset0ToReceive >= lpPosition.asset0Amount - minBalance0 ? 'text-yellow-400' : ''}>
|
||||
{(lpPosition.asset0Amount - asset0ToReceive).toFixed(6)} (min: {minBalance0.toFixed(6)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Remaining {asset1Name}</span>
|
||||
<span className={asset1ToReceive >= lpPosition.asset1Amount - minBalance1 ? 'text-yellow-400' : ''}>
|
||||
{(lpPosition.asset1Amount - asset1ToReceive).toFixed(6)} (min: {minBalance1.toFixed(6)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>Slippage Tolerance</span>
|
||||
<span>5%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRemoveLiquidity}
|
||||
disabled={isLoading || percentage === 0 || percentage > maxRemovablePercentage}
|
||||
className="w-full bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 h-12"
|
||||
>
|
||||
{isLoading ? 'Removing Liquidity...' : 'Remove Liquidity'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DollarSign, TrendingUp, Shield, AlertTriangle, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { getWUSDTTotalSupply, checkReserveHealth, formatWUSDT } from '@pezkuwi/lib/usdt';
|
||||
import { MultisigMembers } from './MultisigMembers';
|
||||
|
||||
interface ReservesDashboardProps {
|
||||
specificAddresses?: Record<string, string>;
|
||||
offChainReserveAmount?: number; // Manual input for MVP
|
||||
}
|
||||
|
||||
export const ReservesDashboard: React.FC<ReservesDashboardProps> = ({
|
||||
specificAddresses = {},
|
||||
offChainReserveAmount = 0,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
|
||||
const [wusdtSupply, setWusdtSupply] = useState(0);
|
||||
const [offChainReserve, setOffChainReserve] = useState(offChainReserveAmount);
|
||||
const [collateralRatio, setCollateralRatio] = useState(0);
|
||||
const [isHealthy, setIsHealthy] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
// Fetch reserve data
|
||||
const fetchReserveData = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const supply = await getWUSDTTotalSupply(api);
|
||||
setWusdtSupply(supply);
|
||||
|
||||
const health = await checkReserveHealth(api, offChainReserve);
|
||||
setCollateralRatio(health.collateralRatio);
|
||||
setIsHealthy(health.isHealthy);
|
||||
setLastUpdate(new Date());
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching reserve data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReserveData();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchReserveData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady, offChainReserve]);
|
||||
|
||||
|
||||
const getHealthColor = () => {
|
||||
if (collateralRatio >= 105) return 'text-green-500';
|
||||
if (collateralRatio >= 100) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const getHealthStatus = () => {
|
||||
if (collateralRatio >= 105) return 'Healthy';
|
||||
if (collateralRatio >= 100) return 'Warning';
|
||||
return 'Critical';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-blue-400" />
|
||||
USDT Reserves Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">Real-time reserve status and multisig info</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchReserveData}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Total Supply */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Total wUSDT Supply</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
${formatWUSDT(wusdtSupply)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">On-chain (Assets pallet)</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Off-chain Reserve */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Off-chain USDT Reserve</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
${formatWUSDT(offChainReserve)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
value={offChainReserve}
|
||||
onChange={(e) => setOffChainReserve(parseFloat(e.target.value) || 0)}
|
||||
className="w-24 text-xs bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white"
|
||||
placeholder="Amount"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={fetchReserveData} className="text-xs h-6">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Collateral Ratio */}
|
||||
<Card className="p-4 bg-gray-800/50 border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Collateral Ratio</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${getHealthColor()}`}>
|
||||
{collateralRatio.toFixed(2)}%
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
variant={isHealthy ? 'default' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{getHealthStatus()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<TrendingUp className={`h-8 w-8 ${getHealthColor()}`} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Health Alert */}
|
||||
{!isHealthy && (
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold">Under-collateralized!</p>
|
||||
<p className="text-sm">
|
||||
Reserve ratio is below 100%. Off-chain USDT reserves ({formatWUSDT(offChainReserve)})
|
||||
are less than on-chain wUSDT supply ({formatWUSDT(wusdtSupply)}).
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-gray-800">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="multisig">Multisig</TabsTrigger>
|
||||
<TabsTrigger value="proof">Proof of Reserves</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Reserve Details</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between p-3 bg-gray-900/50 rounded">
|
||||
<span className="text-gray-300">On-chain wUSDT</span>
|
||||
<span className="text-white font-semibold">${formatWUSDT(wusdtSupply)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between p-3 bg-gray-900/50 rounded">
|
||||
<span className="text-gray-300">Off-chain USDT</span>
|
||||
<span className="text-white font-semibold">${formatWUSDT(offChainReserve)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between p-3 bg-gray-900/50 rounded">
|
||||
<span className="text-gray-300">Backing Ratio</span>
|
||||
<span className={`font-semibold ${getHealthColor()}`}>
|
||||
{collateralRatio.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between p-3 bg-gray-900/50 rounded">
|
||||
<span className="text-gray-300">Status</span>
|
||||
<Badge variant={isHealthy ? 'default' : 'destructive'}>
|
||||
{getHealthStatus()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between p-3 bg-gray-900/50 rounded">
|
||||
<span className="text-gray-300">Last Updated</span>
|
||||
<span className="text-gray-400 text-sm">{lastUpdate.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert className="mt-4 bg-blue-900/20 border-blue-500">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-1">1:1 Backing</p>
|
||||
<p className="text-sm">
|
||||
Every wUSDT is backed by real USDT held in the multisig treasury.
|
||||
Target ratio: ≥100% (ideally 105% for safety buffer).
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Multisig Tab */}
|
||||
<TabsContent value="multisig">
|
||||
<MultisigMembers
|
||||
specificAddresses={specificAddresses}
|
||||
showMultisigAddress={true}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Proof of Reserves Tab */}
|
||||
<TabsContent value="proof" className="space-y-4">
|
||||
<Card className="p-6 bg-gray-800/50 border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Proof of Reserves</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Alert className="bg-green-900/20 border-green-500">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-2">How to Verify Reserves:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>Check on-chain wUSDT supply via Pezkuwi Explorer</li>
|
||||
<li>Verify multisig account balance (if reserves on-chain)</li>
|
||||
<li>Compare with off-chain treasury (bank/exchange account)</li>
|
||||
<li>Ensure ratio ≥ 100%</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-3">Quick Links:</p>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="https://pezkuwichain.io/explorer/assets"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View wUSDT Asset on Explorer
|
||||
</a>
|
||||
<a
|
||||
href="https://pezkuwichain.io/explorer/accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View Multisig Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-orange-900/20 border border-orange-500/30 rounded-lg">
|
||||
<p className="text-sm font-semibold text-orange-400 mb-2">
|
||||
Note: Off-chain Reserves
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
In this MVP implementation, off-chain USDT reserves are manually reported.
|
||||
For full decentralization, consider integrating with oracle services or
|
||||
using XCM bridge for on-chain verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Gift, Calendar, Users, Timer, DollarSign } from 'lucide-react';
|
||||
|
||||
const RewardDistribution: React.FC = () => {
|
||||
const [currentEpoch, setCurrentEpoch] = useState(1);
|
||||
const [trustScoreInput, setTrustScoreInput] = useState(500);
|
||||
const [totalParticipants, setTotalParticipants] = useState(1000);
|
||||
const [totalTrustScore, setTotalTrustScore] = useState(500000);
|
||||
|
||||
const epochRewardPool = 1000000; // 1M PEZ per epoch
|
||||
const parliamentaryAllocation = epochRewardPool * 0.1; // 10% for NFT holders
|
||||
const trustScorePool = epochRewardPool * 0.9; // 90% for trust score rewards
|
||||
const rewardPerTrustPoint = trustScorePool / totalTrustScore;
|
||||
const userReward = trustScoreInput * rewardPerTrustPoint;
|
||||
const nftRewardPerHolder = parliamentaryAllocation / 201;
|
||||
|
||||
const epochPhases = [
|
||||
{ name: 'Active', duration: '30 days', blocks: 432000, status: 'current' },
|
||||
{ name: 'Claim Period', duration: '7 days', blocks: 100800, status: 'upcoming' },
|
||||
{ name: 'Closed', duration: 'Permanent', blocks: 0, status: 'final' }
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gray-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
Reward Distribution System
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
Monthly epoch-based rewards distributed by trust score and NFT holdings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<img
|
||||
src="/pezkuwichain_logo.png"
|
||||
alt="PezkuwiChain Logo"
|
||||
className="w-full h-64 object-cover rounded-xl opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Epoch Timeline */}
|
||||
<div className="lg:col-span-2 bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Calendar className="w-6 h-6 text-purple-400 mr-3" />
|
||||
<h3 className="text-xl font-semibold text-white">Epoch Timeline</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-gray-400">Current Epoch</span>
|
||||
<span className="text-2xl font-bold text-white">#{currentEpoch}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="12"
|
||||
value={currentEpoch}
|
||||
onChange={(e) => setCurrentEpoch(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{epochPhases.map((phase, index) => (
|
||||
<div key={phase.name} className="relative">
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
phase.status === 'current'
|
||||
? 'bg-kurdish-green/20 border-kurdish-green/50'
|
||||
: 'bg-gray-900/50 border-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className={`font-semibold ${
|
||||
phase.status === 'current' ? 'text-purple-400' : 'text-gray-300'
|
||||
}`}>
|
||||
{phase.name}
|
||||
</h4>
|
||||
<div className="flex items-center text-sm">
|
||||
<Timer className="w-4 h-4 mr-1 text-gray-400" />
|
||||
<span className="text-gray-400">{phase.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
{phase.blocks > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{phase.blocks.toLocaleString()} blocks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{index < epochPhases.length - 1 && (
|
||||
<div className="absolute left-8 top-full h-4 w-0.5 bg-gray-700"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<div className="text-gray-400 text-sm mb-1">Epoch Start Block</div>
|
||||
<div className="text-white font-semibold">
|
||||
#{((currentEpoch - 1) * 432000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<div className="text-gray-400 text-sm mb-1">Claim Deadline Block</div>
|
||||
<div className="text-cyan-400 font-semibold">
|
||||
#{((currentEpoch * 432000) + 100800).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reward Pool Info */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Gift className="w-6 h-6 text-cyan-400 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-white">Epoch Pool</h3>
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-white mb-4">
|
||||
{epochRewardPool.toLocaleString()} PEZ
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-400">Trust Score Pool</span>
|
||||
<span className="text-cyan-400 font-semibold">90%</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-400">Parliamentary NFTs</span>
|
||||
<span className="text-purple-400 font-semibold">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="w-6 h-6 text-purple-400 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-white">NFT Rewards</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Total NFTs</span>
|
||||
<span className="text-white">201</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Per NFT Reward</span>
|
||||
<span className="text-purple-400 font-semibold">
|
||||
{Math.floor(nftRewardPerHolder).toLocaleString()} PEZ
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-kurdish-red/20 rounded-lg border border-kurdish-red/30">
|
||||
<div className="text-xs text-purple-400 mb-1">Auto-distributed</div>
|
||||
<div className="text-sm text-gray-300">No claim required</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reward Calculator */}
|
||||
<div className="mt-8 bg-gradient-to-br from-purple-900/20 to-cyan-900/20 backdrop-blur-sm rounded-xl border border-purple-500/30 p-6">
|
||||
<h3 className="text-xl font-semibold text-white mb-6 flex items-center">
|
||||
<DollarSign className="w-6 h-6 text-cyan-400 mr-3" />
|
||||
Reward Calculator
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm block mb-2">Your Trust Score</label>
|
||||
<input
|
||||
type="number"
|
||||
value={trustScoreInput}
|
||||
onChange={(e) => setTrustScoreInput(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm block mb-2">Total Participants</label>
|
||||
<input
|
||||
type="number"
|
||||
value={totalParticipants}
|
||||
onChange={(e) => setTotalParticipants(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm block mb-2">Total Trust Score</label>
|
||||
<input
|
||||
type="number"
|
||||
value={totalTrustScore}
|
||||
onChange={(e) => setTotalTrustScore(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-900/50 rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-400 text-sm mb-1">Reward per Trust Point</div>
|
||||
<div className="text-xl font-semibold text-cyan-400">
|
||||
{rewardPerTrustPoint.toFixed(4)} PEZ
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-gray-400 text-sm mb-1">Your Share</div>
|
||||
<div className="text-xl font-semibold text-purple-400">
|
||||
{((trustScoreInput / totalTrustScore) * 100).toFixed(3)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-gray-400 text-sm mb-1">Estimated Reward</div>
|
||||
<div className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
{Math.floor(userReward).toLocaleString()} PEZ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardDistribution;
|
||||
@@ -0,0 +1,466 @@
|
||||
// ========================================
|
||||
// Route Guard Components
|
||||
// ========================================
|
||||
// Protected route wrappers that check user permissions
|
||||
|
||||
import React, { useEffect, useState, ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
checkCitizenStatus,
|
||||
checkValidatorStatus,
|
||||
checkEducatorRole,
|
||||
checkModeratorRole,
|
||||
} from '@pezkuwi/lib/guards';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, AlertCircle, Users, GraduationCap, Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface RouteGuardProps {
|
||||
children: ReactNode;
|
||||
fallbackPath?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LOADING COMPONENT
|
||||
// ========================================
|
||||
|
||||
const LoadingGuard: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<Card className="bg-gray-900 border-gray-800 p-8">
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-12 h-12 text-green-500 animate-spin" />
|
||||
<p className="text-gray-400">Checking permissions...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// CITIZEN ROUTE GUARD
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* CitizenRoute - Requires approved KYC (citizenship)
|
||||
* Use for: Voting, Education, Elections, etc.
|
||||
*
|
||||
* @example
|
||||
* <Route path="/vote" element={
|
||||
* <CitizenRoute>
|
||||
* <VotingPage />
|
||||
* </CitizenRoute>
|
||||
* } />
|
||||
*/
|
||||
export const CitizenRoute: React.FC<RouteGuardProps> = ({
|
||||
children,
|
||||
fallbackPath = '/be-citizen',
|
||||
}) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const {} = useAuth();
|
||||
const [isCitizen, setIsCitizen] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermission = async () => {
|
||||
if (!isApiReady || !api) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccount?.address) {
|
||||
setIsCitizen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const citizenStatus = await checkCitizenStatus(api, selectedAccount.address);
|
||||
setIsCitizen(citizenStatus);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Citizen check failed:', error);
|
||||
setIsCitizen(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPermission();
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !isApiReady) {
|
||||
return <LoadingGuard />;
|
||||
}
|
||||
|
||||
// Not connected to wallet
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<Users className="w-16 h-16 text-yellow-500" />
|
||||
<h2 className="text-2xl font-bold text-white">Wallet Not Connected</h2>
|
||||
<p className="text-gray-400">
|
||||
Please connect your Pezkuwi wallet to access this feature.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Go to Home
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not a citizen
|
||||
if (isCitizen === false) {
|
||||
return <Navigate to={fallbackPath} replace />;
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// VALIDATOR ROUTE GUARD
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* ValidatorRoute - Requires validator pool membership
|
||||
* Use for: Validator pool dashboard, validator settings
|
||||
*
|
||||
* @example
|
||||
* <Route path="/validator-pool" element={
|
||||
* <ValidatorRoute>
|
||||
* <ValidatorPoolDashboard />
|
||||
* </ValidatorRoute>
|
||||
* } />
|
||||
*/
|
||||
export const ValidatorRoute: React.FC<RouteGuardProps> = ({
|
||||
children,
|
||||
fallbackPath = '/staking',
|
||||
}) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [isValidator, setIsValidator] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermission = async () => {
|
||||
if (!isApiReady || !api) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccount?.address) {
|
||||
setIsValidator(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatorStatus = await checkValidatorStatus(api, selectedAccount.address);
|
||||
setIsValidator(validatorStatus);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Validator check failed:', error);
|
||||
setIsValidator(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPermission();
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !isApiReady) {
|
||||
return <LoadingGuard />;
|
||||
}
|
||||
|
||||
// Not connected to wallet
|
||||
if (!selectedAccount) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Not in validator pool
|
||||
if (isValidator === false) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<CardContent className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Validator Access Required</strong>
|
||||
You must be registered in the Validator Pool to access this feature.
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => window.location.href = fallbackPath}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Go to Staking
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// EDUCATOR ROUTE GUARD
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* EducatorRoute - Requires educator Tiki role
|
||||
* Use for: Creating courses in Perwerde (Education platform)
|
||||
*
|
||||
* @example
|
||||
* <Route path="/education/create-course" element={
|
||||
* <EducatorRoute>
|
||||
* <CourseCreator />
|
||||
* </EducatorRoute>
|
||||
* } />
|
||||
*/
|
||||
export const EducatorRoute: React.FC<RouteGuardProps> = ({
|
||||
children,
|
||||
fallbackPath = '/education',
|
||||
}) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [isEducator, setIsEducator] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermission = async () => {
|
||||
if (!isApiReady || !api) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccount?.address) {
|
||||
setIsEducator(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const educatorStatus = await checkEducatorRole(api, selectedAccount.address);
|
||||
setIsEducator(educatorStatus);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Educator check failed:', error);
|
||||
setIsEducator(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPermission();
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !isApiReady) {
|
||||
return <LoadingGuard />;
|
||||
}
|
||||
|
||||
// Not connected to wallet
|
||||
if (!selectedAccount) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Not an educator
|
||||
if (isEducator === false) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<CardContent className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<GraduationCap className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Educator Role Required</strong>
|
||||
You need one of these Tiki roles to create courses:
|
||||
<ul className="list-disc list-inside mt-2 text-sm">
|
||||
<li>Perwerdekar (Educator)</li>
|
||||
<li>Mamoste (Teacher)</li>
|
||||
<li>WezireCand (Education Minister)</li>
|
||||
<li>Rewsenbîr (Intellectual)</li>
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => window.location.href = fallbackPath}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Browse Courses
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// MODERATOR ROUTE GUARD
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* ModeratorRoute - Requires moderator Tiki role
|
||||
* Use for: Forum moderation, governance moderation
|
||||
*
|
||||
* @example
|
||||
* <Route path="/moderate" element={
|
||||
* <ModeratorRoute>
|
||||
* <ModerationPanel />
|
||||
* </ModeratorRoute>
|
||||
* } />
|
||||
*/
|
||||
export const ModeratorRoute: React.FC<RouteGuardProps> = ({
|
||||
children,
|
||||
fallbackPath = '/',
|
||||
}) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [isModerator, setIsModerator] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermission = async () => {
|
||||
if (!isApiReady || !api) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccount?.address) {
|
||||
setIsModerator(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const moderatorStatus = await checkModeratorRole(api, selectedAccount.address);
|
||||
setIsModerator(moderatorStatus);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Moderator check failed:', error);
|
||||
setIsModerator(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPermission();
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Loading state
|
||||
if (loading || !isApiReady) {
|
||||
return <LoadingGuard />;
|
||||
}
|
||||
|
||||
// Not connected to wallet
|
||||
if (!selectedAccount) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Not a moderator
|
||||
if (isModerator === false) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<CardContent className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<Shield className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Moderator Access Required</strong>
|
||||
You need moderator privileges to access this feature.
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => window.location.href = fallbackPath}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Go to Home
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// ADMIN ROUTE GUARD (Supabase-based)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* AdminRoute - Requires Supabase admin role
|
||||
* Use for: Admin panel, system settings
|
||||
* Note: This is separate from blockchain permissions
|
||||
*/
|
||||
export const AdminRoute: React.FC<RouteGuardProps> = ({
|
||||
children,
|
||||
fallbackPath = '/',
|
||||
}) => {
|
||||
const { user, isAdmin, loading } = useAuth();
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return <LoadingGuard />;
|
||||
}
|
||||
|
||||
// Not logged in
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<CardContent className="p-8">
|
||||
<Alert className="bg-red-900/20 border-red-500">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<AlertDescription className="text-gray-300">
|
||||
<strong className="block mb-2">Admin Access Required</strong>
|
||||
You do not have permission to access the admin panel.
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => window.location.href = fallbackPath}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Go to Home
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface TeamMember {
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
const TeamSection: React.FC = () => {
|
||||
const teamMembers: TeamMember[] = [
|
||||
{
|
||||
name: "Satoshi Qazi Muhammed",
|
||||
role: "Chief Architect",
|
||||
description: "Blockchain visionary and protocol designer",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358016604_9ae228b4.webp"
|
||||
},
|
||||
{
|
||||
name: "Abdurrahman Qasimlo",
|
||||
role: "Governance Lead",
|
||||
description: "Democratic systems and consensus mechanisms",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358018357_f19e128d.webp"
|
||||
},
|
||||
{
|
||||
name: "Abdusselam Barzani",
|
||||
role: "Protocol Engineer",
|
||||
description: "Core protocol development and optimization",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358020150_1ea35457.webp"
|
||||
},
|
||||
{
|
||||
name: "Ihsan Nuri",
|
||||
role: "Security Advisor",
|
||||
description: "Cryptography and network security expert",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358021872_362f1214.webp"
|
||||
},
|
||||
{
|
||||
name: "Seyh Said",
|
||||
role: "Community Director",
|
||||
description: "Ecosystem growth and community relations",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358023648_4bb8f4c7.webp"
|
||||
},
|
||||
{
|
||||
name: "Seyyid Riza",
|
||||
role: "Treasury Manager",
|
||||
description: "Economic models and treasury operations",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358025533_d9df77a9.webp"
|
||||
},
|
||||
{
|
||||
name: "Beritan",
|
||||
role: "Developer Relations",
|
||||
description: "Technical documentation and developer support",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358027281_9254657a.webp"
|
||||
},
|
||||
{
|
||||
name: "Mashuk Xaznevi",
|
||||
role: "Research Lead",
|
||||
description: "Blockchain research and innovation",
|
||||
image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358029000_3ffc04bc.webp"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gradient-to-b from-gray-900 to-black">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<Badge className="mb-4 bg-kurdish-green/20 text-kurdish-green border-kurdish-green/30">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Our Team
|
||||
</Badge>
|
||||
<h2 className="text-4xl font-bold text-white mb-4">
|
||||
Meet the Visionaries
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
A dedicated team of blockchain experts and governance specialists building the future of decentralized democracy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{teamMembers.map((member, index) => (
|
||||
<Card key={index} className="bg-gray-900/50 border-gray-800 hover:border-kurdish-green/50 transition-all duration-300 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-kurdish-green via-kurdish-red to-kurdish-yellow p-1 mb-4">
|
||||
<img
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-1 group-hover:text-kurdish-green transition-colors">
|
||||
{member.name}
|
||||
</h3>
|
||||
<Badge className="mb-3 bg-kurdish-red/20 text-kurdish-red border-kurdish-red/30">
|
||||
{member.role}
|
||||
</Badge>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{member.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamSection;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PieChart, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
const TokenomicsSection: React.FC = () => {
|
||||
const [selectedToken, setSelectedToken] = useState<'PEZ' | 'HEZ'>('PEZ');
|
||||
const [monthsPassed] = useState(0);
|
||||
|
||||
const halvingPeriod = Math.floor(monthsPassed / 48);
|
||||
//const _monthsUntilNextHalving = 48 - (monthsPassed % 48);
|
||||
|
||||
useEffect(() => {
|
||||
const baseAmount = selectedToken === 'PEZ' ? 74218750 : 37109375;
|
||||
// Calculate release amount for future use
|
||||
const releaseAmount = baseAmount / Math.pow(2, halvingPeriod);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Release amount:', releaseAmount);
|
||||
}, [monthsPassed, halvingPeriod, selectedToken]);
|
||||
|
||||
const pezDistribution = [
|
||||
{ name: 'Treasury', percentage: 96.25, amount: 4812500000, color: 'from-purple-500 to-purple-600' },
|
||||
{ name: 'Presale', percentage: 1.875, amount: 93750000, color: 'from-cyan-500 to-cyan-600' },
|
||||
{ name: 'Founder', percentage: 1.875, amount: 93750000, color: 'from-teal-500 to-teal-600' }
|
||||
];
|
||||
|
||||
const hezDistribution = [
|
||||
{ name: 'Staking Rewards', percentage: 40, amount: 1000000000, color: 'from-yellow-500 to-orange-600' },
|
||||
{ name: 'Governance', percentage: 30, amount: 750000000, color: 'from-green-500 to-emerald-600' },
|
||||
{ name: 'Ecosystem', percentage: 20, amount: 500000000, color: 'from-blue-500 to-indigo-600' },
|
||||
{ name: 'Team', percentage: 10, amount: 250000000, color: 'from-red-500 to-pink-600' }
|
||||
];
|
||||
|
||||
const distribution = selectedToken === 'PEZ' ? pezDistribution : hezDistribution;
|
||||
const totalSupply = selectedToken === 'PEZ' ? 5000000000 : 2500000000;
|
||||
const tokenColor = selectedToken === 'PEZ' ? 'purple' : 'yellow';
|
||||
|
||||
return (
|
||||
<section id="tokenomics" className="py-20 bg-gray-900/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-yellow-400 bg-clip-text text-transparent">
|
||||
Dual Token Ecosystem
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-6">
|
||||
PEZ & HEZ tokens working together for governance and utility
|
||||
</p>
|
||||
|
||||
{/* Token Selector */}
|
||||
<div className="inline-flex bg-gray-950/50 rounded-lg p-1 border border-gray-800">
|
||||
<button
|
||||
onClick={() => setSelectedToken('PEZ')}
|
||||
className={`px-6 py-2 rounded-md font-semibold transition-all ${
|
||||
selectedToken === 'PEZ'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
PEZ Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedToken('HEZ')}
|
||||
className={`px-6 py-2 rounded-md font-semibold transition-all ${
|
||||
selectedToken === 'HEZ'
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
HEZ Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Distribution Chart */}
|
||||
<div className="bg-gray-950/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<PieChart className={`w-6 h-6 text-${tokenColor}-400 mr-3`} />
|
||||
<h3 className="text-xl font-semibold text-white">{selectedToken} Distribution</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className={`w-48 h-48 rounded-full bg-gradient-to-br from-${tokenColor}-500 to-${tokenColor}-700 flex items-center justify-center`}>
|
||||
<span className="text-white text-3xl font-bold">{selectedToken}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{distribution.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-3 h-3 rounded-full bg-gradient-to-r ${item.color} mr-3`}></div>
|
||||
<span className="text-gray-300">{item.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-white font-semibold">{item.percentage}%</div>
|
||||
<div className="text-gray-500 text-sm">{item.amount.toLocaleString()} {selectedToken}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 p-4 bg-${tokenColor}-500/20 rounded-lg border border-${tokenColor}-500/30`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-${tokenColor}-400`}>Total Supply</span>
|
||||
<span className="text-white font-bold">{totalSupply.toLocaleString()} {selectedToken}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Features */}
|
||||
<div className="bg-gray-950/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<ArrowRightLeft className={`w-6 h-6 text-${tokenColor}-400 mr-3`} />
|
||||
<h3 className="text-xl font-semibold text-white">{selectedToken} Features</h3>
|
||||
</div>
|
||||
|
||||
{selectedToken === 'PEZ' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-purple-900/20 rounded-lg border border-purple-500/30">
|
||||
<h4 className="text-purple-400 font-semibold mb-2">Governance Token</h4>
|
||||
<p className="text-gray-300 text-sm">Vote on proposals and participate in DAO decisions</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-900/20 rounded-lg border border-purple-500/30">
|
||||
<h4 className="text-purple-400 font-semibold mb-2">Staking Rewards</h4>
|
||||
<p className="text-gray-300 text-sm">Earn HEZ tokens by staking PEZ</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-900/20 rounded-lg border border-purple-500/30">
|
||||
<h4 className="text-purple-400 font-semibold mb-2">Treasury Access</h4>
|
||||
<p className="text-gray-300 text-sm">Propose and vote on treasury fund allocation</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-900/20 rounded-lg border border-purple-500/30">
|
||||
<h4 className="text-purple-400 font-semibold mb-2">Deflationary</h4>
|
||||
<p className="text-gray-300 text-sm">Synthetic halving every 48 months</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-yellow-900/20 rounded-lg border border-yellow-500/30">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2">Utility Token</h4>
|
||||
<p className="text-gray-300 text-sm">Used for platform transactions and services</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-900/20 rounded-lg border border-yellow-500/30">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2">P2P Trading</h4>
|
||||
<p className="text-gray-300 text-sm">Primary currency for peer-to-peer marketplace</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-900/20 rounded-lg border border-yellow-500/30">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2">Fee Discounts</h4>
|
||||
<p className="text-gray-300 text-sm">Reduced platform fees when using HEZ</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-900/20 rounded-lg border border-yellow-500/30">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2">Reward Distribution</h4>
|
||||
<p className="text-gray-300 text-sm">Earned through staking and participation</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-purple-900/20 to-yellow-900/20 rounded-lg border border-gray-700">
|
||||
<h4 className="text-white font-semibold mb-3">Token Synergy</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center text-gray-300">
|
||||
<span className="w-2 h-2 bg-purple-400 rounded-full mr-2"></span>
|
||||
Stake PEZ → Earn HEZ rewards
|
||||
</div>
|
||||
<div className="flex items-center text-gray-300">
|
||||
<span className="w-2 h-2 bg-yellow-400 rounded-full mr-2"></span>
|
||||
Use HEZ → Boost governance power
|
||||
</div>
|
||||
<div className="flex items-center text-gray-300">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||
Hold both → Maximum platform benefits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenomicsSection;
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { History, ExternalLink, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface TransactionHistoryProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
blockNumber: number;
|
||||
extrinsicIndex: number;
|
||||
hash: string;
|
||||
method: string;
|
||||
section: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
amount?: string;
|
||||
success: boolean;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export const TransactionHistory: React.FC<TransactionHistoryProps> = ({ isOpen, onClose }) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Fetching transactions...');
|
||||
const currentBlock = await api.rpc.chain.getBlock();
|
||||
const currentBlockNumber = currentBlock.block.header.number.toNumber();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Current block number:', currentBlockNumber);
|
||||
|
||||
const txList: Transaction[] = [];
|
||||
const blocksToCheck = Math.min(200, currentBlockNumber);
|
||||
|
||||
for (let i = 0; i < blocksToCheck && txList.length < 20; i++) {
|
||||
const blockNumber = currentBlockNumber - i;
|
||||
|
||||
try {
|
||||
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
|
||||
const block = await api.rpc.chain.getBlock(blockHash);
|
||||
|
||||
// Try to get timestamp, but don't fail if state is pruned
|
||||
let timestamp = 0;
|
||||
try {
|
||||
const ts = await api.query.timestamp.now.at(blockHash);
|
||||
timestamp = ts.toNumber();
|
||||
} catch {
|
||||
// State pruned, use current time as fallback
|
||||
timestamp = Date.now();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Block #${blockNumber}: ${block.block.extrinsics.length} extrinsics`);
|
||||
|
||||
// Check each extrinsic in the block
|
||||
block.block.extrinsics.forEach((extrinsic, index) => {
|
||||
// Skip unsigned extrinsics (system calls)
|
||||
if (!extrinsic.isSigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { method, signer } = extrinsic;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(` Extrinsic #${index}: ${method.section}.${method.method}, signer: ${signer.toString()}`);
|
||||
|
||||
// Check if transaction involves our account
|
||||
const fromAddress = signer.toString();
|
||||
const isFromOurAccount = fromAddress === selectedAccount.address;
|
||||
|
||||
// Only track transactions from this account
|
||||
if (!isFromOurAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse balances.transfer or balances.transferKeepAlive
|
||||
if (method.section === 'balances' &&
|
||||
(method.method === 'transfer' || method.method === 'transferKeepAlive')) {
|
||||
const [dest, value] = method.args;
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
to: dest.toString(),
|
||||
amount: value.toString(),
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse assets.transfer (PEZ, USDT, etc.)
|
||||
else if (method.section === 'assets' && method.method === 'transfer') {
|
||||
const [assetId, dest, value] = method.args;
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: `${method.method} (Asset ${assetId.toString()})`,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
to: dest.toString(),
|
||||
amount: value.toString(),
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse staking operations
|
||||
else if (method.section === 'staking') {
|
||||
if (method.method === 'bond' || method.method === 'bondExtra') {
|
||||
const value = method.args[method.method === 'bond' ? 1 : 0];
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
amount: value.toString(),
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
} else if (method.method === 'unbond') {
|
||||
const [value] = method.args;
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
amount: value.toString(),
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
} else if (method.method === 'nominate' || method.method === 'withdrawUnbonded' || method.method === 'chill') {
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse DEX operations
|
||||
else if (method.section === 'dex') {
|
||||
if (method.method === 'swap') {
|
||||
const [, amountIn] = method.args;
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
amount: amountIn.toString(),
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
} else if (method.method === 'addLiquidity' || method.method === 'removeLiquidity') {
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse stakingScore operations
|
||||
else if (method.section === 'stakingScore' && method.method === 'startTracking') {
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse pezRewards operations
|
||||
else if (method.section === 'pezRewards' && method.method === 'claimReward') {
|
||||
txList.push({
|
||||
blockNumber,
|
||||
extrinsicIndex: index,
|
||||
hash: extrinsic.hash.toHex(),
|
||||
method: method.method,
|
||||
section: method.section,
|
||||
from: fromAddress,
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (blockError) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn(`Error processing block #${blockNumber}:`, blockError);
|
||||
// Continue to next block
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Found transactions:', txList.length);
|
||||
|
||||
setTransactions(txList);
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch transactions:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch transaction history",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTransactions();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, api, isApiReady, selectedAccount]);
|
||||
|
||||
|
||||
const formatAmount = (amount: string, decimals: number = 12) => {
|
||||
const value = parseInt(amount) / Math.pow(10, decimals);
|
||||
return value.toFixed(4);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp?: number) => {
|
||||
if (!timestamp) return 'Unknown';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const isIncoming = (tx: Transaction) => {
|
||||
return tx.to === selectedAccount?.address;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-white">Transaction History</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Recent transactions involving your account
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={fetchTransactions}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto max-h-[500px]">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-12 h-12 text-gray-600 mx-auto mb-3 animate-spin" />
|
||||
<p className="text-gray-400">Loading transactions...</p>
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">No transactions found</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Your recent transactions will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
transactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.blockNumber}-${tx.extrinsicIndex}`}
|
||||
className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isIncoming(tx) ? (
|
||||
<div className="bg-green-500/20 p-2 rounded-lg">
|
||||
<ArrowDownRight className="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-500/20 p-2 rounded-lg">
|
||||
<ArrowUpRight className="w-4 h-4 text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-white font-semibold">
|
||||
{isIncoming(tx) ? 'Received' : 'Sent'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{tx.section}.{tx.method}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-white font-mono">
|
||||
{isIncoming(tx) ? '+' : '-'}{formatAmount(tx.amount || '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Block #{tx.blockNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500">From:</span>
|
||||
<div className="text-gray-300 font-mono">
|
||||
{tx.from.slice(0, 8)}...{tx.from.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
{tx.to && (
|
||||
<div>
|
||||
<span className="text-gray-500">To:</span>
|
||||
<div className="text-gray-300 font-mono">
|
||||
{tx.to.slice(0, 8)}...{tx.to.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimestamp(tx.timestamp)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: "Transaction Details",
|
||||
description: `Block #${tx.blockNumber}, Extrinsic #${tx.extrinsicIndex}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface TokenBalance {
|
||||
assetId: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
balance: string;
|
||||
decimals: number;
|
||||
usdValue: number;
|
||||
}
|
||||
|
||||
interface TransferModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedAsset?: TokenBalance | null;
|
||||
}
|
||||
|
||||
type TokenType = 'HEZ' | 'PEZ' | 'USDT' | 'BTC' | 'ETH' | 'DOT';
|
||||
|
||||
interface Token {
|
||||
symbol: TokenType;
|
||||
name: string;
|
||||
assetId?: number;
|
||||
decimals: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Token logo mapping
|
||||
const TOKEN_LOGOS: Record<string, string> = {
|
||||
HEZ: '/tokens/HEZ.png',
|
||||
PEZ: '/tokens/PEZ.png',
|
||||
USDT: '/tokens/USDT.png',
|
||||
BTC: '/tokens/BTC.png',
|
||||
ETH: '/tokens/ETH.png',
|
||||
DOT: '/tokens/DOT.png',
|
||||
BNB: '/tokens/BNB.png',
|
||||
};
|
||||
|
||||
const TOKENS: Token[] = [
|
||||
{ symbol: 'HEZ', name: 'Hez Token', decimals: 12, color: 'from-green-600 to-yellow-400' },
|
||||
{ symbol: 'PEZ', name: 'Pez Token', assetId: 1, decimals: 12, color: 'from-blue-600 to-purple-400' },
|
||||
{ symbol: 'USDT', name: 'Tether USD', assetId: 1000, decimals: 6, color: 'from-green-500 to-green-600' },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', assetId: 3, decimals: 8, color: 'from-orange-500 to-yellow-500' },
|
||||
{ symbol: 'ETH', name: 'Ethereum', assetId: 4, decimals: 18, color: 'from-purple-500 to-blue-500' },
|
||||
{ symbol: 'DOT', name: 'Polkadot', assetId: 5, decimals: 10, color: 'from-pink-500 to-red-500' },
|
||||
];
|
||||
|
||||
export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, selectedAsset }) => {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [selectedToken, setSelectedToken] = useState<TokenType>('HEZ');
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle');
|
||||
const [txHash, setTxHash] = useState('');
|
||||
|
||||
// Use the provided selectedAsset or fall back to token selection
|
||||
const currentToken = selectedAsset ? {
|
||||
symbol: selectedAsset.symbol as TokenType,
|
||||
name: selectedAsset.name,
|
||||
assetId: selectedAsset.assetId,
|
||||
decimals: selectedAsset.decimals,
|
||||
color: selectedAsset.assetId === 0 ? 'from-green-600 to-yellow-400' :
|
||||
selectedAsset.assetId === 1000 ? 'from-emerald-500 to-teal-500' :
|
||||
'from-cyan-500 to-blue-500',
|
||||
} : TOKENS.find(t => t.symbol === selectedToken) || TOKENS[0];
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Wallet not connected",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recipient || !amount) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please fill in all fields",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTransferring(true);
|
||||
setTxStatus('signing');
|
||||
|
||||
try {
|
||||
// Import web3FromAddress to get the injector
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Convert amount to smallest unit
|
||||
const amountInSmallestUnit = BigInt(parseFloat(amount) * Math.pow(10, currentToken.decimals));
|
||||
|
||||
let transfer;
|
||||
|
||||
// Create appropriate transfer transaction based on token type
|
||||
// wHEZ uses native token transfer (balances pallet), all others use assets pallet
|
||||
if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) {
|
||||
// Native HEZ token transfer
|
||||
transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString());
|
||||
} else {
|
||||
// Asset token transfer (wHEZ, PEZ, wUSDT, etc.)
|
||||
transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString());
|
||||
}
|
||||
|
||||
setTxStatus('pending');
|
||||
|
||||
// Sign and send transaction
|
||||
const unsub = await transfer.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Transaction included in block: ${status.asInBlock}`);
|
||||
setTxHash(status.asInBlock.toHex());
|
||||
}
|
||||
|
||||
if (status.isFinalized) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Transaction finalized: ${status.asFinalized}`);
|
||||
|
||||
// Check for errors
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`;
|
||||
}
|
||||
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: "Transfer Failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: "Transfer Successful!",
|
||||
description: `Sent ${amount} ${currentToken.symbol} to ${recipient.slice(0, 8)}...${recipient.slice(-6)}`,
|
||||
});
|
||||
|
||||
// Reset form after 2 seconds
|
||||
setTimeout(() => {
|
||||
setRecipient('');
|
||||
setAmount('');
|
||||
setTxStatus('idle');
|
||||
setTxHash('');
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
setIsTransferring(false);
|
||||
unsub();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Transfer error:', error);
|
||||
setTxStatus('error');
|
||||
setIsTransferring(false);
|
||||
|
||||
toast({
|
||||
title: "Transfer Failed",
|
||||
description: error instanceof Error ? error.message : "An error occurred during transfer",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isTransferring) {
|
||||
setRecipient('');
|
||||
setAmount('');
|
||||
setTxStatus('idle');
|
||||
setTxHash('');
|
||||
setSelectedToken('HEZ');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{selectedAsset ? `Send ${selectedAsset.symbol}` : 'Send Tokens'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
{selectedAsset
|
||||
? `Transfer ${selectedAsset.name} to another account`
|
||||
: 'Transfer tokens to another account'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{txStatus === 'success' ? (
|
||||
<div className="py-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Transfer Successful!</h3>
|
||||
<p className="text-gray-400 mb-4">Your transaction has been finalized</p>
|
||||
{txHash && (
|
||||
<div className="bg-gray-800/50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Transaction Hash</div>
|
||||
<div className="text-white font-mono text-xs break-all">
|
||||
{txHash}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : txStatus === 'error' ? (
|
||||
<div className="py-8 text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Transfer Failed</h3>
|
||||
<p className="text-gray-400">Please try again</p>
|
||||
<Button
|
||||
onClick={() => setTxStatus('idle')}
|
||||
className="mt-4 bg-gray-800 hover:bg-gray-700"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Token Selection - Only show if no asset is pre-selected */}
|
||||
{!selectedAsset && (
|
||||
<div>
|
||||
<Label htmlFor="token" className="text-white">Select Token</Label>
|
||||
<Select value={selectedToken} onValueChange={(value) => setSelectedToken(value as TokenType)} disabled={isTransferring}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700 text-white mt-2">
|
||||
<SelectValue placeholder="Select token" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-700">
|
||||
{TOKENS.map((token) => (
|
||||
<SelectItem
|
||||
key={token.symbol}
|
||||
value={token.symbol}
|
||||
className="text-white hover:bg-gray-700 focus:bg-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={TOKEN_LOGOS[token.symbol]}
|
||||
alt={token.symbol}
|
||||
className="w-5 h-5 rounded-full object-cover"
|
||||
/>
|
||||
<span className="font-semibold">{token.symbol}</span>
|
||||
<span className="text-gray-400 text-sm">- {token.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="recipient" className="text-white">Recipient Address</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="Recipient address"
|
||||
className="bg-gray-800 border-gray-700 text-white mt-2 placeholder:text-gray-500 placeholder:opacity-50"
|
||||
disabled={isTransferring}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="amount" className="text-white">Amount ({selectedToken})</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step={selectedToken === 'HEZ' || selectedToken === 'PEZ' ? '0.0001' : '0.000001'}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="bg-gray-800 border-gray-700 text-white mt-2 placeholder:text-gray-500 placeholder:opacity-50"
|
||||
disabled={isTransferring}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Decimals: {currentToken.decimals}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{txStatus === 'signing' && (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||
<p className="text-yellow-400 text-sm">
|
||||
Please sign the transaction in your Pezkuwi.js extension
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{txStatus === 'pending' && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-3">
|
||||
<p className="text-blue-400 text-sm flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Transaction pending... Waiting for finalization
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleTransfer}
|
||||
disabled={isTransferring || !recipient || !amount}
|
||||
className={`w-full bg-gradient-to-r ${currentToken.color} hover:opacity-90`}
|
||||
>
|
||||
{isTransferring ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{txStatus === 'signing' ? 'Waiting for signature...' : 'Processing...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Send {selectedToken}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calculator, TrendingUp, Users, BookOpen, Award } from 'lucide-react';
|
||||
|
||||
const TrustScoreCalculator: React.FC = () => {
|
||||
const [stakedAmount, setStakedAmount] = useState(100);
|
||||
const [stakingMonths, setStakingMonths] = useState(6);
|
||||
const [referralCount, setReferralCount] = useState(5);
|
||||
const [perwerdeScore, setPerwerdeScore] = useState(30);
|
||||
const [tikiScore, setTikiScore] = useState(40);
|
||||
const [finalScore, setFinalScore] = useState(0);
|
||||
|
||||
// Calculate base amount score based on pallet_staking_score logic
|
||||
const getAmountScore = (amount: number) => {
|
||||
if (amount <= 100) return 20;
|
||||
if (amount <= 250) return 30;
|
||||
if (amount <= 750) return 40;
|
||||
return 50; // 751+ HEZ
|
||||
};
|
||||
|
||||
// Calculate staking multiplier based on months
|
||||
const getStakingMultiplier = (months: number) => {
|
||||
if (months < 1) return 1.0;
|
||||
if (months < 3) return 1.2;
|
||||
if (months < 6) return 1.4;
|
||||
if (months < 12) return 1.7;
|
||||
return 2.0;
|
||||
};
|
||||
|
||||
// Calculate referral score
|
||||
const getReferralScore = (count: number) => {
|
||||
if (count === 0) return 0;
|
||||
if (count <= 5) return count * 4;
|
||||
if (count <= 20) return 20 + ((count - 5) * 2);
|
||||
return 50;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const amountScore = getAmountScore(stakedAmount);
|
||||
const multiplier = getStakingMultiplier(stakingMonths);
|
||||
const adjustedStaking = Math.min(amountScore * multiplier, 100);
|
||||
const adjustedReferral = getReferralScore(referralCount);
|
||||
|
||||
const weightedSum =
|
||||
adjustedStaking * 100 +
|
||||
adjustedReferral * 300 +
|
||||
perwerdeScore * 300 +
|
||||
tikiScore * 300;
|
||||
|
||||
const score = (adjustedStaking * weightedSum) / 1000;
|
||||
setFinalScore(Math.round(score));
|
||||
}, [stakedAmount, stakingMonths, referralCount, perwerdeScore, tikiScore]);
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gray-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
Trust Score Calculator
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
Simulate your trust score based on staking, referrals, education, and roles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Calculator Inputs */}
|
||||
<div className="space-y-6">
|
||||
{/* Staking Score */}
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<TrendingUp className="w-5 h-5 text-purple-400 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-white">Staking Amount</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm">Staked Amount (HEZ)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="10"
|
||||
value={stakedAmount}
|
||||
onChange={(e) => setStakedAmount(parseInt(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-cyan-400">{stakedAmount} HEZ</span>
|
||||
<span className="text-purple-400">Score: {getAmountScore(stakedAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm">Staking Duration (Months)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="24"
|
||||
value={stakingMonths}
|
||||
onChange={(e) => setStakingMonths(parseInt(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-cyan-400">{stakingMonths} months</span>
|
||||
<span className="text-purple-400">×{getStakingMultiplier(stakingMonths).toFixed(1)} multiplier</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral Score */}
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="w-5 h-5 text-cyan-400 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-white">Referral Score</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-gray-400 text-sm">Number of Referrals</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={referralCount}
|
||||
onChange={(e) => setReferralCount(parseInt(e.target.value) || 0)}
|
||||
className="w-full mt-2 px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-2 text-sm text-cyan-400">
|
||||
Score: {getReferralScore(referralCount)} points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Scores */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<BookOpen className="w-5 h-5 text-teal-400 mr-3" />
|
||||
<h3 className="text-sm font-semibold text-white">Perwerde Score</h3>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={perwerdeScore}
|
||||
onChange={(e) => setPerwerdeScore(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-center mt-2 text-teal-400">{perwerdeScore}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Award className="w-5 h-5 text-purple-400 mr-3" />
|
||||
<h3 className="text-sm font-semibold text-white">Tiki Score</h3>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={tikiScore}
|
||||
onChange={(e) => setTikiScore(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-center mt-2 text-purple-400">{tikiScore}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results and Formula */}
|
||||
<div className="space-y-6">
|
||||
{/* Final Score */}
|
||||
<div className="bg-gradient-to-br from-purple-900/30 to-cyan-900/30 backdrop-blur-sm rounded-xl border border-purple-500/50 p-8 text-center">
|
||||
<Calculator className="w-12 h-12 text-cyan-400 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-semibold text-white mb-2">Final Trust Score</h3>
|
||||
<div className="text-6xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
{finalScore}
|
||||
</div>
|
||||
<div className="mt-4 text-gray-400">
|
||||
Out of theoretical maximum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula Breakdown */}
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Formula Breakdown</h3>
|
||||
|
||||
<div className="bg-gray-950/50 rounded-lg p-4 font-mono text-sm">
|
||||
<div className="text-purple-400 mb-2">
|
||||
weighted_sum =
|
||||
</div>
|
||||
<div className="text-gray-300 ml-4">
|
||||
staking × 100 +
|
||||
</div>
|
||||
<div className="text-gray-300 ml-4">
|
||||
referral × 300 +
|
||||
</div>
|
||||
<div className="text-gray-300 ml-4">
|
||||
perwerde × 300 +
|
||||
</div>
|
||||
<div className="text-gray-300 ml-4 mb-2">
|
||||
tiki × 300
|
||||
</div>
|
||||
<div className="text-cyan-400">
|
||||
final_score = staking × weighted_sum / 1000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Staking Component:</span>
|
||||
<span className="text-purple-400">{Math.min(Math.round(getAmountScore(stakedAmount) * getStakingMultiplier(stakingMonths)), 100)} × 100</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Referral Component:</span>
|
||||
<span className="text-cyan-400">{getReferralScore(referralCount)} × 300</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Perwerde Component:</span>
|
||||
<span className="text-teal-400">{perwerdeScore} × 300</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Tiki Component:</span>
|
||||
<span className="text-purple-400">{tikiScore} × 300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Impact */}
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Score Impact</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-400">Monthly Rewards Eligibility</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${finalScore > 100 ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'}`}>
|
||||
{finalScore > 100 ? 'Eligible' : 'Not Eligible'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-400">Governance Voting Weight</span>
|
||||
<span className="text-cyan-400 font-semibold">{Math.min(Math.floor(finalScore / 100), 10)}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrustScoreCalculator;
|
||||
@@ -0,0 +1,354 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ArrowDown, ArrowUp, AlertCircle, Info, Clock, CheckCircle2 } from 'lucide-react';
|
||||
import { web3FromAddress } from '@pezkuwi/extension-dapp';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
getWUSDTBalance,
|
||||
calculateWithdrawalDelay,
|
||||
getWithdrawalTier,
|
||||
formatDelay,
|
||||
formatWUSDT,
|
||||
} from '@pezkuwi/lib/usdt';
|
||||
import { isMultisigMember } from '@pezkuwi/lib/multisig';
|
||||
import { ASSET_IDS } from '@pezkuwi/lib/wallet';
|
||||
|
||||
interface USDTBridgeProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
specificAddresses?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const USDTBridge: React.FC<USDTBridgeProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
specificAddresses = {},
|
||||
}) => {
|
||||
const { api, selectedAccount, isApiReady } = usePezkuwi();
|
||||
const { refreshBalances } = useWallet();
|
||||
|
||||
const [depositAmount, setDepositAmount] = useState('');
|
||||
const [withdrawAmount, setWithdrawAmount] = useState('');
|
||||
const [withdrawAddress, setWithdrawAddress] = useState(''); // Bank account or crypto address
|
||||
const [wusdtBalance, setWusdtBalance] = useState(0);
|
||||
const [isMultisigMemberState, setIsMultisigMemberState] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch wUSDT balance
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedAccount || !isOpen) return;
|
||||
|
||||
const fetchBalance = async () => {
|
||||
const balance = await getWUSDTBalance(api, selectedAccount.address);
|
||||
setWusdtBalance(balance);
|
||||
|
||||
// Check if user is multisig member
|
||||
const isMember = await isMultisigMember(api, selectedAccount.address, specificAddresses);
|
||||
setIsMultisigMemberState(isMember);
|
||||
};
|
||||
|
||||
fetchBalance();
|
||||
}, [api, isApiReady, selectedAccount, isOpen, specificAddresses]);
|
||||
|
||||
// Handle deposit (user requests deposit)
|
||||
const handleDeposit = async () => {
|
||||
if (!depositAmount || parseFloat(depositAmount) <= 0) {
|
||||
setError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// In real implementation:
|
||||
// 1. User transfers USDT to treasury (off-chain)
|
||||
// 2. Notary verifies the transfer
|
||||
// 3. Multisig mints wUSDT to user
|
||||
|
||||
// For now, just show instructions
|
||||
setSuccess(
|
||||
`Deposit request for ${depositAmount} USDT created. Please follow the instructions to complete the deposit.`
|
||||
);
|
||||
setDepositAmount('');
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Deposit error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Deposit failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle withdrawal (burn wUSDT)
|
||||
const handleWithdrawal = async () => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
const amount = parseFloat(withdrawAmount);
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
setError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount > wusdtBalance) {
|
||||
setError('Insufficient wUSDT balance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!withdrawAddress) {
|
||||
setError('Please enter withdrawal address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Burn wUSDT
|
||||
const amountBN = BigInt(Math.floor(amount * 1e6)); // 6 decimals
|
||||
const burnTx = api.tx.assets.burn(ASSET_IDS.WUSDT, selectedAccount.address, amountBN.toString());
|
||||
|
||||
await burnTx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status }) => {
|
||||
if (status.isFinalized) {
|
||||
const delay = calculateWithdrawalDelay(amount);
|
||||
setSuccess(
|
||||
`Withdrawal request submitted! wUSDT burned. USDT will be sent to ${withdrawAddress} after ${formatDelay(delay)}.`
|
||||
);
|
||||
setWithdrawAmount('');
|
||||
setWithdrawAddress('');
|
||||
refreshBalances();
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Withdrawal error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Withdrawal failed');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const withdrawalTier = withdrawAmount ? getWithdrawalTier(parseFloat(withdrawAmount)) : null;
|
||||
const withdrawalDelay = withdrawAmount ? calculateWithdrawalDelay(parseFloat(withdrawAmount)) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 rounded-lg max-w-2xl w-full p-6 border border-gray-700 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">USDT Bridge</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">Deposit or withdraw USDT</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Balance Display */}
|
||||
<div className="mb-6 p-4 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-1">Your wUSDT Balance</p>
|
||||
<p className="text-3xl font-bold text-white">{formatWUSDT(wusdtBalance)}</p>
|
||||
{isMultisigMemberState && (
|
||||
<Badge variant="outline" className="mt-2">
|
||||
Multisig Member
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error/Success Alerts */}
|
||||
{error && (
|
||||
<Alert className="mb-4 bg-red-900/20 border-red-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="mb-4 bg-green-900/20 border-green-500">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertDescription>{success}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="deposit" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-gray-800">
|
||||
<TabsTrigger value="deposit">Deposit</TabsTrigger>
|
||||
<TabsTrigger value="withdraw">Withdraw</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Deposit Tab */}
|
||||
<TabsContent value="deposit" className="space-y-4 mt-4">
|
||||
<Alert className="bg-blue-900/20 border-blue-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<p className="font-semibold mb-2">How to Deposit:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Transfer USDT to the treasury account (off-chain)</li>
|
||||
<li>Notary verifies and records your transaction</li>
|
||||
<li>Multisig (3/5) approves and mints wUSDT to your account</li>
|
||||
<li>Receive wUSDT in 2-5 minutes</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
USDT Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={depositAmount}
|
||||
onChange={(e) => setDepositAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">You will receive:</span>
|
||||
<span className="text-white font-semibold">
|
||||
{depositAmount || '0.00'} wUSDT
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Exchange rate:</span>
|
||||
<span className="text-white">1:1</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Estimated time:</span>
|
||||
<span className="text-white">2-5 minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDeposit}
|
||||
disabled={isLoading || !depositAmount}
|
||||
className="w-full bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 h-12"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Processing...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
Request Deposit
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* Withdraw Tab */}
|
||||
<TabsContent value="withdraw" className="space-y-4 mt-4">
|
||||
<Alert className="bg-orange-900/20 border-orange-500">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<p className="font-semibold mb-2">How to Withdraw:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Burn your wUSDT on-chain</li>
|
||||
<li>Wait for security delay ({withdrawalDelay > 0 && formatDelay(withdrawalDelay)})</li>
|
||||
<li>Multisig (3/5) approves and sends USDT</li>
|
||||
<li>Receive USDT to your specified address</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
wUSDT Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
max={wusdtBalance}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setWithdrawAmount(wusdtBalance.toString())}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-1"
|
||||
>
|
||||
Max: {formatWUSDT(wusdtBalance)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Withdrawal Address (Bank Account or Crypto Address)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={withdrawAddress}
|
||||
onChange={(e) => setWithdrawAddress(e.target.value)}
|
||||
placeholder="Bank account or crypto address"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{withdrawAmount && parseFloat(withdrawAmount) > 0 && (
|
||||
<div className="p-4 bg-gray-800 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">You will receive:</span>
|
||||
<span className="text-white font-semibold">{withdrawAmount} USDT</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Withdrawal tier:</span>
|
||||
<Badge variant={withdrawalTier === 'Large' ? 'destructive' : 'outline'}>
|
||||
{withdrawalTier}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Security delay:</span>
|
||||
<span className="text-white flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatDelay(withdrawalDelay)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleWithdrawal}
|
||||
disabled={isLoading || !withdrawAmount || !withdrawAddress}
|
||||
className="w-full bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 h-12"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Processing...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
Withdraw USDT
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Loader2, Plus, CheckCircle, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { COMMISSIONS } from '@/config/commissions';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
export function CommissionSetupTab() {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commissionMembers, setCommissionMembers] = useState<string[]>([]);
|
||||
const [setupComplete, setSetupComplete] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [newMemberAddress, setNewMemberAddress] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) return;
|
||||
checkSetup();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady]);
|
||||
|
||||
|
||||
const checkSetup = async () => {
|
||||
if (!api) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Check DynamicCommissionCollective members
|
||||
const members = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = members.toJSON() as string[];
|
||||
|
||||
setCommissionMembers(memberList);
|
||||
// Commission is initialized if there's at least one member
|
||||
setSetupComplete(memberList.length > 0);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Commission members:', memberList);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Setup complete:', memberList.length > 0);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error checking setup:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your admin wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newMemberAddress) {
|
||||
toast({
|
||||
title: 'No Addresses',
|
||||
description: 'Please enter at least one address',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Parse addresses (one per line, trim whitespace)
|
||||
const newAddresses = newMemberAddress
|
||||
.split('\n')
|
||||
.map(addr => addr.trim())
|
||||
.filter(addr => addr.length > 0);
|
||||
|
||||
if (newAddresses.length === 0) {
|
||||
toast({
|
||||
title: 'No Valid Addresses',
|
||||
description: 'Please enter at least one valid address',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current members
|
||||
const currentMembers = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = (currentMembers.toJSON() as string[]) || [];
|
||||
|
||||
// Filter out already existing members
|
||||
const newMembers = newAddresses.filter(addr => !memberList.includes(addr));
|
||||
|
||||
if (newMembers.length === 0) {
|
||||
toast({
|
||||
title: 'Already Members',
|
||||
description: 'All addresses are already commission members',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new members
|
||||
const updatedList = [...memberList, ...newMembers];
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Adding new members:', newMembers);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Updated member list:', updatedList);
|
||||
|
||||
const tx = api.tx.sudo.sudo(
|
||||
api.tx.dynamicCommissionCollective.setMembers(
|
||||
updatedList,
|
||||
null,
|
||||
updatedList.length
|
||||
)
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Failed to add member';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${newMembers.length} member(s) added successfully!`,
|
||||
});
|
||||
setNewMemberAddress('');
|
||||
setTimeout(() => checkSetup(), 2000);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error adding member:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to add member',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitializeCommission = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your admin wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Initializing KYC Commission...');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Proxy account:', COMMISSIONS.KYC.proxyAccount);
|
||||
|
||||
// Initialize DynamicCommissionCollective with Alice as first member
|
||||
// Other members can be added later
|
||||
const tx = api.tx.sudo.sudo(
|
||||
api.tx.dynamicCommissionCollective.setMembers(
|
||||
[selectedAccount.address], // Add caller as first member
|
||||
null,
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Setup error:', errorMessage);
|
||||
toast({
|
||||
title: 'Setup Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Sudid event
|
||||
const sudidEvent = events.find(({ event }) =>
|
||||
event.section === 'sudo' && event.method === 'Sudid'
|
||||
);
|
||||
|
||||
if (sudidEvent) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ KYC Commission initialized');
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'KYC Commission initialized successfully!',
|
||||
});
|
||||
resolve();
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Transaction included but no Sudid event');
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to sign and send:', error);
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Reload setup status
|
||||
setTimeout(() => checkSetup(), 2000);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error initializing commission:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to initialize commission',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isApiReady) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
<span className="ml-3 text-gray-400">Connecting to blockchain...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Please connect your admin wallet to manage commission setup.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Setup Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
KYC Commission Setup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-cyan-500" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div>
|
||||
<p className="font-medium">Commission Status</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{setupComplete
|
||||
? 'Commission is initialized and ready'
|
||||
: 'Commission needs to be initialized'}
|
||||
</p>
|
||||
</div>
|
||||
{setupComplete ? (
|
||||
<Badge className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Not Initialized
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-400">Proxy Account</p>
|
||||
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||
<p className="font-mono text-xs">{COMMISSIONS.KYC.proxyAccount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-400">
|
||||
Commission Members ({commissionMembers.length})
|
||||
</p>
|
||||
{commissionMembers.length === 0 ? (
|
||||
<div className="p-4 bg-gray-800/50 rounded border border-gray-700 text-center text-gray-500">
|
||||
No members yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{commissionMembers.map((member, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-gray-800/50 rounded border border-gray-700"
|
||||
>
|
||||
<p className="font-mono text-xs">{member}</p>
|
||||
{member === COMMISSIONS.KYC.proxyAccount && (
|
||||
<Badge className="mt-2 bg-cyan-600 text-xs">KYC Proxy</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!setupComplete && (
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/30">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Required:</strong> Initialize the commission before members can join.
|
||||
This requires sudo privileges.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{setupComplete && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-400">Add Members</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Get wallet addresses from Pezkuwi.js extension
|
||||
// For now, show instruction
|
||||
toast({
|
||||
title: 'Get Addresses',
|
||||
description: 'Copy addresses from Pezkuwi.js and paste below',
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
How to get addresses
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
placeholder="Member addresses, one per line"
|
||||
value={newMemberAddress}
|
||||
onChange={(e) => setNewMemberAddress(e.target.value)}
|
||||
className="flex-1 font-mono text-sm p-3 bg-gray-800 border border-gray-700 rounded min-h-[120px] placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddMember}
|
||||
disabled={processing || !newMemberAddress}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{processing ? 'Adding Members...' : 'Add Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleInitializeCommission}
|
||||
disabled={setupComplete || processing}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Initializing...
|
||||
</>
|
||||
) : setupComplete ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Already Initialized
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Initialize Commission
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={checkSetup}
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Setup Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-400">
|
||||
<li>
|
||||
<strong className="text-white">Initialize Commission</strong> - Add proxy to
|
||||
DynamicCommissionCollective (requires sudo)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white">Join Commission</strong> - Members add proxy rights
|
||||
via Commission Voting tab
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white">Start Voting</strong> - Create proposals and vote on
|
||||
KYC applications
|
||||
</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Loader2, ThumbsUp, ThumbsDown, Clock, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { COMMISSIONS } from '@/config/commissions';
|
||||
|
||||
interface Proposal {
|
||||
hash: string;
|
||||
proposalIndex: number;
|
||||
threshold: number;
|
||||
ayes: string[];
|
||||
nays: string[];
|
||||
end: number;
|
||||
call?: unknown;
|
||||
}
|
||||
|
||||
export function CommissionVotingTab() {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [voting, setVoting] = useState<string | null>(null);
|
||||
const [isCommissionMember, setIsCommissionMember] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkMembership();
|
||||
loadProposals();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
|
||||
const checkMembership = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('No API or selected account');
|
||||
setIsCommissionMember(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Checking membership for:', selectedAccount.address);
|
||||
|
||||
// Check if user is directly a member of DynamicCommissionCollective
|
||||
const members = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = members.toJSON() as string[];
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Commission members:', memberList);
|
||||
|
||||
const isMember = memberList.includes(selectedAccount.address);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Is commission member:', isMember);
|
||||
|
||||
setIsCommissionMember(isMember);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error checking membership:', error);
|
||||
setIsCommissionMember(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProposals = async () => {
|
||||
if (!api || !isApiReady) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get all active proposal hashes
|
||||
const proposalHashes = await api.query.dynamicCommissionCollective.proposals();
|
||||
|
||||
const proposalList: Proposal[] = [];
|
||||
|
||||
for (let i = 0; i < proposalHashes.length; i++) {
|
||||
const hash = proposalHashes[i];
|
||||
|
||||
// Get voting info for this proposal
|
||||
const voting = await api.query.dynamicCommissionCollective.voting(hash);
|
||||
|
||||
if (!voting.isEmpty) {
|
||||
const voteData = voting.unwrap();
|
||||
|
||||
// Get proposal details
|
||||
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(hash);
|
||||
let proposalCall = null;
|
||||
|
||||
if (!proposalOption.isEmpty) {
|
||||
proposalCall = proposalOption.unwrap();
|
||||
}
|
||||
|
||||
// Get the actual proposal index from the chain
|
||||
const proposalIndex = (voteData as Record<string, unknown>).index?.toNumber() || i;
|
||||
|
||||
proposalList.push({
|
||||
hash: hash.toHex(),
|
||||
proposalIndex: proposalIndex,
|
||||
threshold: voteData.threshold.toNumber(),
|
||||
ayes: voteData.ayes.map((a: { toString: () => string }) => a.toString()),
|
||||
nays: voteData.nays.map((n: { toString: () => string }) => n.toString()),
|
||||
end: voteData.end.toNumber(),
|
||||
call: proposalCall?.toHuman(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProposals(proposalList);
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Loaded ${proposalList.length} active proposals`);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading proposals:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load proposals',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (proposal: Proposal, approve: boolean) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCommissionMember) {
|
||||
toast({
|
||||
title: 'Not a Commission Member',
|
||||
description: 'You are not a member of the KYC Approval Commission',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setVoting(proposal.hash);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Voting ${approve ? 'AYE' : 'NAY'} on proposal:`, proposal.hash);
|
||||
|
||||
// Vote directly (no proxy needed)
|
||||
const tx = api.tx.dynamicCommissionCollective.vote(
|
||||
proposal.hash,
|
||||
proposal.proposalIndex,
|
||||
approve
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Vote error:', errorMessage);
|
||||
toast({
|
||||
title: 'Vote Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Voted event
|
||||
const votedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Voted'
|
||||
);
|
||||
|
||||
// Check for Executed event (threshold reached)
|
||||
const executedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
|
||||
);
|
||||
|
||||
if (executedEvent) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Proposal executed (threshold reached)');
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Proposal passed and executed! KYC approved.',
|
||||
});
|
||||
} else if (votedEvent) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Vote recorded');
|
||||
toast({
|
||||
title: 'Vote Recorded',
|
||||
description: `Your ${approve ? 'AYE' : 'NAY'} vote has been recorded`,
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to sign and send:', error);
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Reload proposals after voting
|
||||
setTimeout(() => {
|
||||
loadProposals();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error voting:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to vote',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setVoting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (proposal: Proposal) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setVoting(proposal.hash);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Executing proposal:', proposal.hash);
|
||||
|
||||
// Get proposal length bound
|
||||
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(proposal.hash);
|
||||
const proposalCall = proposalOption.unwrap();
|
||||
const lengthBound = proposalCall.encodedLength;
|
||||
|
||||
const tx = api.tx.dynamicCommissionCollective.close(
|
||||
proposal.hash,
|
||||
proposal.proposalIndex,
|
||||
{
|
||||
refTime: 1_000_000_000_000, // 1 trillion for ref time
|
||||
proofSize: 64 * 1024, // 64 KB for proof size
|
||||
},
|
||||
lengthBound
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Execute error:', errorMessage);
|
||||
toast({
|
||||
title: 'Execute Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const executedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
|
||||
);
|
||||
|
||||
const closedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Closed'
|
||||
);
|
||||
|
||||
if (executedEvent) {
|
||||
const eventData = executedEvent.event.data.toHuman();
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Proposal executed');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Execute event data:', eventData);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Result:', eventData);
|
||||
|
||||
// Check if execution was successful
|
||||
const result = eventData[eventData.length - 1]; // Last parameter is usually the result
|
||||
if (result && typeof result === 'object' && 'Err' in result) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Execution failed:', result.Err);
|
||||
toast({
|
||||
title: 'Execution Failed',
|
||||
description: `Proposal closed but execution failed: ${JSON.stringify(result.Err)}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Proposal Executed!',
|
||||
description: 'KYC approved and NFT minted successfully!',
|
||||
});
|
||||
}
|
||||
} else if (closedEvent) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Proposal closed');
|
||||
toast({
|
||||
title: 'Proposal Closed',
|
||||
description: 'Proposal has been closed',
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to sign and send:', error);
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
loadProposals();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error executing:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to execute proposal',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setVoting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getProposalDescription = (call: Record<string, unknown>): string => {
|
||||
if (!call) return 'Unknown proposal';
|
||||
|
||||
try {
|
||||
const callStr = JSON.stringify(call);
|
||||
if (callStr.includes('approveKyc')) {
|
||||
return 'KYC Approval';
|
||||
}
|
||||
if (callStr.includes('rejectKyc')) {
|
||||
return 'KYC Rejection';
|
||||
}
|
||||
return 'Commission Action';
|
||||
} catch {
|
||||
return 'Unknown proposal';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (proposal: Proposal) => {
|
||||
const progress = (proposal.ayes.length / proposal.threshold) * 100;
|
||||
|
||||
if (proposal.ayes.length >= proposal.threshold) {
|
||||
return <Badge variant="default" className="bg-green-600">PASSED</Badge>;
|
||||
}
|
||||
if (progress >= 50) {
|
||||
return <Badge variant="default" className="bg-yellow-600">VOTING ({progress.toFixed(0)}%)</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">VOTING ({progress.toFixed(0)}%)</Badge>;
|
||||
};
|
||||
|
||||
if (!isApiReady) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
<span>Connecting to blockchain...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Please connect your wallet to view commission proposals</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCommissionMember) {
|
||||
const handleJoinCommission = async () => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Get current members
|
||||
const currentMembers = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = (currentMembers.toJSON() as string[]) || [];
|
||||
|
||||
// Add current user to members list
|
||||
if (!memberList.includes(selectedAccount.address)) {
|
||||
memberList.push(selectedAccount.address);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Adding member to commission:', selectedAccount.address);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('New member list:', memberList);
|
||||
|
||||
// Use sudo to update members (requires sudo access)
|
||||
const tx = api.tx.sudo.sudo(
|
||||
api.tx.dynamicCommissionCollective.setMembers(
|
||||
memberList,
|
||||
null,
|
||||
memberList.length
|
||||
)
|
||||
);
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Failed to join commission';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'You have joined the KYC Commission!',
|
||||
});
|
||||
setTimeout(() => checkMembership(), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to join commission',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-4">You are not a member of the KYC Approval Commission</p>
|
||||
<p className="text-sm text-muted-foreground mb-6">Only commission members can view and vote on proposals</p>
|
||||
<Button
|
||||
onClick={handleJoinCommission}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Join Commission
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Commission Proposals</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Active voting proposals for {COMMISSIONS.KYC.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadProposals}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
<span>Loading proposals...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : proposals.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Clock className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No active proposals</p>
|
||||
<p className="text-sm mt-2">Proposals will appear here when commission members create them</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Proposals ({proposals.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Proposal</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Votes</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proposals.map((proposal) => (
|
||||
<TableRow key={proposal.hash}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
#{proposal.proposalIndex}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getProposalDescription(proposal.call)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(proposal)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
{proposal.ayes.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
{proposal.nays.length}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/ {proposal.threshold}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
{proposal.ayes.length >= proposal.threshold ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleExecute(proposal)}
|
||||
disabled={voting === proposal.hash}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>Execute Proposal</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleVote(proposal, true)}
|
||||
disabled={voting === proposal.hash}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
Aye
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleVote(proposal, false)}
|
||||
disabled={voting === proposal.hash}
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
Nay
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
import { formatAddress, formatDate } from '@pezkuwi/utils/formatting';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
FileText,
|
||||
Gavel,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Shield,
|
||||
User,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Dispute {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
opened_by: string;
|
||||
reason: string;
|
||||
category: string;
|
||||
evidence_urls: string[];
|
||||
status: 'open' | 'under_review' | 'resolved' | 'escalated' | 'closed';
|
||||
decision?: string;
|
||||
decision_reasoning?: string;
|
||||
assigned_moderator_id?: string;
|
||||
assigned_at?: string;
|
||||
resolved_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Joined data
|
||||
trade?: Trade;
|
||||
opener?: UserProfile;
|
||||
evidence?: Evidence[];
|
||||
}
|
||||
|
||||
interface Trade {
|
||||
id: string;
|
||||
offer_id: string;
|
||||
seller_id: string;
|
||||
buyer_id: string;
|
||||
crypto_amount: number;
|
||||
fiat_amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
seller?: UserProfile;
|
||||
buyer?: UserProfile;
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
username?: string;
|
||||
wallet_address?: string;
|
||||
}
|
||||
|
||||
interface Evidence {
|
||||
id: string;
|
||||
dispute_id: string;
|
||||
uploaded_by: string;
|
||||
evidence_type: string;
|
||||
file_url: string;
|
||||
file_name?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
is_valid?: boolean;
|
||||
review_notes?: string;
|
||||
}
|
||||
|
||||
// Decision options
|
||||
const DECISION_OPTIONS = [
|
||||
{ value: 'release_to_buyer', label: 'Release to Buyer', description: 'Release escrowed crypto to the buyer' },
|
||||
{ value: 'refund_to_seller', label: 'Refund to Seller', description: 'Return escrowed crypto to the seller' },
|
||||
{ value: 'split', label: 'Split 50/50', description: 'Split the escrowed amount between both parties' },
|
||||
{ value: 'escalate', label: 'Escalate', description: 'Escalate to higher authority for complex cases' }
|
||||
];
|
||||
|
||||
// Status badge colors
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30',
|
||||
under_review: 'bg-blue-500/20 text-blue-500 border-blue-500/30',
|
||||
resolved: 'bg-green-500/20 text-green-500 border-green-500/30',
|
||||
escalated: 'bg-purple-500/20 text-purple-500 border-purple-500/30',
|
||||
closed: 'bg-gray-500/20 text-gray-400 border-gray-500/30'
|
||||
};
|
||||
|
||||
// Category labels
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
payment_not_received: 'Payment Not Received',
|
||||
wrong_amount: 'Wrong Amount',
|
||||
fake_payment_proof: 'Fake Payment Proof',
|
||||
seller_not_responding: 'Seller Not Responding',
|
||||
buyer_not_responding: 'Buyer Not Responding',
|
||||
fraudulent_behavior: 'Fraudulent Behavior',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
export function DisputeResolutionPanel() {
|
||||
const [disputes, setDisputes] = useState<Dispute[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('open');
|
||||
const [decision, setDecision] = useState('');
|
||||
const [reasoning, setReasoning] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
||||
|
||||
// Fetch disputes
|
||||
const fetchDisputes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.select(`
|
||||
*,
|
||||
trade:p2p_fiat_trades(
|
||||
id,
|
||||
offer_id,
|
||||
seller_id,
|
||||
buyer_id,
|
||||
crypto_amount,
|
||||
fiat_amount,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch evidence for each dispute
|
||||
const disputesWithEvidence = await Promise.all(
|
||||
(data || []).map(async (dispute) => {
|
||||
const { data: evidence } = await supabase
|
||||
.from('p2p_dispute_evidence')
|
||||
.select('*')
|
||||
.eq('dispute_id', dispute.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
return {
|
||||
...dispute,
|
||||
evidence: evidence || []
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setDisputes(disputesWithEvidence);
|
||||
} catch (error) {
|
||||
console.error('Error fetching disputes:', error);
|
||||
toast.error('Failed to load disputes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDisputes();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.channel('admin-disputes')
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'p2p_fiat_disputes'
|
||||
}, () => {
|
||||
fetchDisputes();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter disputes by status
|
||||
const filteredDisputes = disputes.filter(d => {
|
||||
if (activeTab === 'open') return d.status === 'open';
|
||||
if (activeTab === 'under_review') return d.status === 'under_review';
|
||||
if (activeTab === 'resolved') return ['resolved', 'closed'].includes(d.status);
|
||||
if (activeTab === 'escalated') return d.status === 'escalated';
|
||||
return true;
|
||||
});
|
||||
|
||||
// Claim dispute for review
|
||||
const claimDispute = async (disputeId: string) => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: 'under_review',
|
||||
assigned_moderator_id: user.id,
|
||||
assigned_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', disputeId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Dispute claimed for review');
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error claiming dispute:', error);
|
||||
toast.error('Failed to claim dispute');
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve dispute
|
||||
const resolveDispute = async () => {
|
||||
if (!selectedDispute || !decision || !reasoning) {
|
||||
toast.error('Please select a decision and provide reasoning');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Update dispute
|
||||
const { error: disputeError } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: decision === 'escalate' ? 'escalated' : 'resolved',
|
||||
decision,
|
||||
decision_reasoning: reasoning,
|
||||
resolved_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', selectedDispute.id);
|
||||
|
||||
if (disputeError) throw disputeError;
|
||||
|
||||
// Update trade status based on decision
|
||||
if (decision !== 'escalate' && selectedDispute.trade) {
|
||||
const tradeStatus = decision === 'release_to_buyer' ? 'completed' : 'refunded';
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({ status: tradeStatus })
|
||||
.eq('id', selectedDispute.trade_id);
|
||||
}
|
||||
|
||||
// Create notifications for both parties
|
||||
if (selectedDispute.trade) {
|
||||
const notificationPromises = [
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.seller_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${DECISION_OPTIONS.find(o => o.value === decision)?.label}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
}),
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.buyer_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${DECISION_OPTIONS.find(o => o.value === decision)?.label}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
})
|
||||
];
|
||||
await Promise.all(notificationPromises);
|
||||
}
|
||||
|
||||
toast.success('Dispute resolved successfully');
|
||||
setResolveOpen(false);
|
||||
setSelectedDispute(null);
|
||||
setDecision('');
|
||||
setReasoning('');
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error resolving dispute:', error);
|
||||
toast.error('Failed to resolve dispute');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open details modal
|
||||
const openDetails = (dispute: Dispute) => {
|
||||
setSelectedDispute(dispute);
|
||||
setDetailsOpen(true);
|
||||
};
|
||||
|
||||
// Open resolve modal
|
||||
const openResolve = (dispute: Dispute) => {
|
||||
setSelectedDispute(dispute);
|
||||
setResolveOpen(true);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
open: disputes.filter(d => d.status === 'open').length,
|
||||
under_review: disputes.filter(d => d.status === 'under_review').length,
|
||||
resolved: disputes.filter(d => ['resolved', 'closed'].includes(d.status)).length,
|
||||
escalated: disputes.filter(d => d.status === 'escalated').length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Gavel className="h-6 w-6 text-kurdish-green" />
|
||||
Dispute Resolution
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Review and resolve P2P trading disputes
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchDisputes} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-yellow-500/10 border-yellow-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Open</p>
|
||||
<p className="text-2xl font-bold text-yellow-500">{stats.open}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-yellow-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-500/10 border-blue-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Under Review</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{stats.under_review}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-500/10 border-green-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Resolved</p>
|
||||
<p className="text-2xl font-bold text-green-500">{stats.resolved}</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-500/10 border-purple-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Escalated</p>
|
||||
<p className="text-2xl font-bold text-purple-500">{stats.escalated}</p>
|
||||
</div>
|
||||
<Scale className="h-8 w-8 text-purple-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Disputes Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-4 w-full max-w-md">
|
||||
<TabsTrigger value="open" className="gap-1">
|
||||
Open
|
||||
{stats.open > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
|
||||
{stats.open}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="under_review">In Review</TabsTrigger>
|
||||
<TabsTrigger value="resolved">Resolved</TabsTrigger>
|
||||
<TabsTrigger value="escalated">Escalated</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredDisputes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Shield className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No disputes in this category</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDisputes.map((dispute) => (
|
||||
<Card key={dispute.id} className="hover:border-kurdish-green/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={STATUS_COLORS[dispute.status]}>
|
||||
{dispute.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{CATEGORY_LABELS[dispute.category] || dispute.category}
|
||||
</Badge>
|
||||
{dispute.evidence && dispute.evidence.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
{dispute.evidence.length} evidence
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
||||
{dispute.reason}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
Trade: {formatAddress(dispute.trade_id)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(dispute.created_at)}
|
||||
</span>
|
||||
{dispute.trade && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Scale className="h-3 w-3" />
|
||||
{dispute.trade.crypto_amount} crypto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDetails(dispute)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
|
||||
{dispute.status === 'open' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => claimDispute(dispute.id)}
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{dispute.status === 'under_review' && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={() => openResolve(dispute)}
|
||||
>
|
||||
<Gavel className="h-4 w-4 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5" />
|
||||
Dispute Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review all information related to this dispute
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedDispute && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-6">
|
||||
{/* Status & Category */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={STATUS_COLORS[selectedDispute.status]}>
|
||||
{selectedDispute.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{CATEGORY_LABELS[selectedDispute.category] || selectedDispute.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Reason</h4>
|
||||
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
|
||||
{selectedDispute.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trade Info */}
|
||||
{selectedDispute.trade && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Trade Information</h4>
|
||||
<div className="bg-muted p-3 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Trade ID:</span>
|
||||
<span className="font-mono">{formatAddress(selectedDispute.trade_id)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Amount:</span>
|
||||
<span>{selectedDispute.trade.crypto_amount} crypto</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fiat:</span>
|
||||
<span>{selectedDispute.trade.fiat_amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Trade Status:</span>
|
||||
<Badge variant="secondary">{selectedDispute.trade.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parties */}
|
||||
{selectedDispute.trade && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Parties</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Seller</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
{formatAddress(selectedDispute.trade.seller_id)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Buyer</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
{formatAddress(selectedDispute.trade.buyer_id)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Evidence ({selectedDispute.evidence?.length || 0})
|
||||
</h4>
|
||||
{selectedDispute.evidence && selectedDispute.evidence.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{selectedDispute.evidence.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="bg-muted p-3 rounded-lg cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => setLightboxImage(ev.file_url)}
|
||||
>
|
||||
<div className="aspect-video relative mb-2 rounded overflow-hidden bg-black/20">
|
||||
{ev.evidence_type === 'screenshot' || ev.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<img
|
||||
src={ev.file_url}
|
||||
alt={ev.description || 'Evidence'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<Badge variant="outline" className="mb-1">
|
||||
{ev.evidence_type}
|
||||
</Badge>
|
||||
{ev.description && (
|
||||
<p className="text-muted-foreground line-clamp-2 mt-1">
|
||||
{ev.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No evidence uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Timeline</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-muted-foreground">Opened:</span>
|
||||
<span>{formatDate(selectedDispute.created_at)}</span>
|
||||
</div>
|
||||
{selectedDispute.assigned_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-muted-foreground">Claimed:</span>
|
||||
<span>{formatDate(selectedDispute.assigned_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedDispute.resolved_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-muted-foreground">Resolved:</span>
|
||||
<span>{formatDate(selectedDispute.resolved_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution (if resolved) */}
|
||||
{selectedDispute.decision && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Resolution</h4>
|
||||
<div className="bg-green-500/10 border border-green-500/20 p-3 rounded-lg">
|
||||
<Badge className="bg-green-500/20 text-green-500 mb-2">
|
||||
{DECISION_OPTIONS.find(o => o.value === selectedDispute.decision)?.label}
|
||||
</Badge>
|
||||
{selectedDispute.decision_reasoning && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedDispute.decision_reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setDetailsOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{selectedDispute?.status === 'under_review' && (
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={() => {
|
||||
setDetailsOpen(false);
|
||||
openResolve(selectedDispute);
|
||||
}}
|
||||
>
|
||||
<Gavel className="h-4 w-4 mr-2" />
|
||||
Resolve Dispute
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
<Dialog open={resolveOpen} onOpenChange={setResolveOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Gavel className="h-5 w-5 text-kurdish-green" />
|
||||
Resolve Dispute
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make a final decision on this dispute. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Decision */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Decision</label>
|
||||
<Select value={decision} onValueChange={setDecision}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a decision..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DECISION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Reasoning <span className="text-muted-foreground">(required)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={reasoning}
|
||||
onChange={(e) => setReasoning(e.target.value)}
|
||||
placeholder="Explain your decision based on the evidence..."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This will be visible to both parties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-500">Important</p>
|
||||
<p className="text-muted-foreground">
|
||||
Your decision will trigger automatic actions on the escrowed funds.
|
||||
Make sure you have reviewed all evidence carefully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setResolveOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={resolveDispute}
|
||||
disabled={submitting || !decision || !reasoning}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Confirm Resolution
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
<Dialog open={!!lightboxImage} onOpenChange={() => setLightboxImage(null)}>
|
||||
<DialogContent className="max-w-4xl p-0 bg-black/90">
|
||||
{lightboxImage && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={lightboxImage}
|
||||
alt="Evidence"
|
||||
className="w-full h-auto max-h-[80vh] object-contain"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => window.open(lightboxImage, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
asChild
|
||||
>
|
||||
<a href={lightboxImage} download>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => setLightboxImage(null)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisputeResolutionPanel;
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Loader2, CheckCircle, XCircle, Clock, User, Mail, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { COMMISSIONS } from '@/config/commissions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
interface PendingApplication {
|
||||
address: string;
|
||||
cids: string[];
|
||||
notes: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface IdentityInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function KycApprovalTab() {
|
||||
const { api, isApiReady, selectedAccount, connectWallet } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pendingApps, setPendingApps] = useState<PendingApplication[]>([]);
|
||||
const [identities, setIdentities] = useState<Map<string, IdentityInfo>>(new Map());
|
||||
const [selectedApp, setSelectedApp] = useState<PendingApplication | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
|
||||
// Load pending KYC applications
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadPendingApplications();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady]);
|
||||
|
||||
|
||||
const loadPendingApplications = async () => {
|
||||
if (!api || !isApiReady) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get all pending applications
|
||||
const entries = await api.query.identityKyc.pendingKycApplications.entries();
|
||||
|
||||
const apps: PendingApplication[] = [];
|
||||
const identityMap = new Map<string, IdentityInfo>();
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const address = key.args[0].toString();
|
||||
const application = value.toJSON() as Record<string, unknown>;
|
||||
|
||||
// Get identity info for this address
|
||||
try {
|
||||
const identity = await api.query.identityKyc.identities(address);
|
||||
if (!identity.isEmpty) {
|
||||
const identityData = identity.toJSON() as Record<string, unknown>;
|
||||
identityMap.set(address, {
|
||||
name: identityData.name || 'Unknown',
|
||||
email: identityData.email || 'No email'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error fetching identity for', address, err);
|
||||
}
|
||||
|
||||
apps.push({
|
||||
address,
|
||||
cids: application.cids || [],
|
||||
notes: application.notes || 'No notes provided',
|
||||
timestamp: application.timestamp || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
setPendingApps(apps);
|
||||
setIdentities(identityMap);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Loaded ${apps.length} pending KYC applications`);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading pending applications:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load pending applications',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (application: PendingApplication) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your admin wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Proposing KYC approval for:', application.address);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Commission member wallet:', selectedAccount.address);
|
||||
|
||||
// Check if user is a member of DynamicCommissionCollective
|
||||
const members = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = members.toJSON() as string[];
|
||||
const isMember = memberList.includes(selectedAccount.address);
|
||||
|
||||
if (!isMember) {
|
||||
toast({
|
||||
title: 'Not a Commission Member',
|
||||
description: 'You are not a member of the KYC Approval Commission',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ User is commission member');
|
||||
|
||||
// Create proposal for KYC approval
|
||||
const proposal = api.tx.identityKyc.approveKyc(application.address);
|
||||
const lengthBound = proposal.encodedLength;
|
||||
|
||||
// Create proposal directly (no proxy needed)
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Creating commission proposal for KYC approval');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Applicant:', application.address);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Threshold:', COMMISSIONS.KYC.threshold);
|
||||
|
||||
const tx = api.tx.dynamicCommissionCollective.propose(
|
||||
COMMISSIONS.KYC.threshold,
|
||||
proposal,
|
||||
lengthBound
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction created:', tx.toHuman());
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Approval error:', errorMessage);
|
||||
toast({
|
||||
title: 'Approval Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Proposed event
|
||||
if (process.env.NODE_ENV !== 'production') console.log('All events:', events.map(e => `${e.event.section}.${e.event.method}`));
|
||||
const proposedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Proposed'
|
||||
);
|
||||
|
||||
if (proposedEvent) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ KYC Approval proposal created');
|
||||
toast({
|
||||
title: 'Proposal Created',
|
||||
description: `KYC approval proposed for ${application.address.slice(0, 8)}... Waiting for other commission members to vote.`,
|
||||
});
|
||||
resolve();
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Transaction included but no Proposed event');
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to sign and send:', error);
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Reload applications after approval
|
||||
setTimeout(() => {
|
||||
loadPendingApplications();
|
||||
setShowDetailsModal(false);
|
||||
setSelectedApp(null);
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error approving KYC:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to approve KYC',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (application: PendingApplication) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your admin wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmReject = window.confirm(
|
||||
`Are you sure you want to REJECT KYC for ${application.address}?\n\nThis will slash their deposit.`
|
||||
);
|
||||
|
||||
if (!confirmReject) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Rejecting KYC for:', application.address);
|
||||
|
||||
const tx = api.tx.identityKyc.rejectKyc(application.address);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Rejection Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const rejectedEvent = events.find(({ event }) =>
|
||||
event.section === 'identityKyc' && event.method === 'KycRejected'
|
||||
);
|
||||
|
||||
if (rejectedEvent) {
|
||||
toast({
|
||||
title: 'Rejected',
|
||||
description: `KYC rejected for ${application.address.slice(0, 8)}...`,
|
||||
});
|
||||
resolve();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
loadPendingApplications();
|
||||
setShowDetailsModal(false);
|
||||
setSelectedApp(null);
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error rejecting KYC:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to reject KYC',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetailsModal = (app: PendingApplication) => {
|
||||
setSelectedApp(app);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
if (!isApiReady) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
<span className="ml-3 text-gray-400">Connecting to blockchain...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Please connect your admin wallet to view and approve KYC applications.
|
||||
<Button onClick={connectWallet} variant="outline" className="ml-4">
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Pending KYC Applications</CardTitle>
|
||||
<Button onClick={loadPendingApplications} variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Refresh'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
</div>
|
||||
) : pendingApps.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<p className="text-gray-400">No pending applications</p>
|
||||
<p className="text-sm text-gray-600 mt-2">All KYC applications have been processed</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Applicant</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Documents</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingApps.map((app) => {
|
||||
const identity = identities.get(app.address);
|
||||
return (
|
||||
<TableRow key={app.address}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{app.address.slice(0, 6)}...{app.address.slice(-4)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
{identity?.name || 'Loading...'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
{identity?.email || 'Loading...'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
{app.cids.length} CID(s)
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openDetailsModal(app)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Dialog open={showDetailsModal} onOpenChange={setShowDetailsModal}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>KYC Application Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review application before approving or rejecting
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedApp && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-400">Applicant Address</Label>
|
||||
<p className="font-mono text-sm mt-1">{selectedApp.address}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400">Name</Label>
|
||||
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400">Email</Label>
|
||||
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.email || 'No email'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400">Application Time</Label>
|
||||
<p className="text-sm mt-1">
|
||||
{selectedApp.timestamp
|
||||
? new Date(selectedApp.timestamp).toLocaleString()
|
||||
: 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-gray-400">Notes</Label>
|
||||
<p className="text-sm mt-1 p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||
{selectedApp.notes}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-gray-400">IPFS Documents ({selectedApp.cids.length})</Label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedApp.cids.map((cid, index) => (
|
||||
<div key={index} className="p-2 bg-gray-800/50 rounded border border-gray-700">
|
||||
<p className="font-mono text-xs">{cid}</p>
|
||||
<a
|
||||
href={`https://ipfs.io/ipfs/${cid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400 hover:text-cyan-300 text-xs"
|
||||
>
|
||||
View on IPFS →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/30">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<strong>Important:</strong> Approving this application will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Unreserve the applicant's deposit</li>
|
||||
<li>Mint a Welati (Citizen) NFT automatically</li>
|
||||
<li>Enable trust score tracking</li>
|
||||
<li>Grant governance voting rights</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => selectedApp && handleReject(selectedApp)}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <XCircle className="w-4 h-4 mr-2" />}
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectedApp && handleApprove(selectedApp)}
|
||||
disabled={processing}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <label className={`text-sm font-medium ${className}`}>{children}</label>;
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
/**
|
||||
* XCM Configuration Wizard - Multi-Step Parachain Setup
|
||||
*
|
||||
* Guides admin through complete XCM integration:
|
||||
* 1. Reserve ParaId
|
||||
* 2. Generate Chain Artifacts (genesis + WASM)
|
||||
* 3. Register Parachain
|
||||
* 4. Open HRMP Channels
|
||||
* 5. Register Foreign Assets
|
||||
* 6. Test XCM Transfer
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import {
|
||||
X,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Download,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
reserveParaId,
|
||||
generateChainArtifacts,
|
||||
registerParachain,
|
||||
openHRMPChannels,
|
||||
registerForeignAssets,
|
||||
testXCMTransfer,
|
||||
getRelayChainEndpoint,
|
||||
getAssetHubParaId,
|
||||
type RelayChain,
|
||||
type ChainArtifacts,
|
||||
type HRMPChannel,
|
||||
type RegisteredAsset,
|
||||
type ForeignAsset,
|
||||
} from '@pezkuwi/lib/xcm-wizard';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
|
||||
interface XCMConfigurationWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface StepStatus {
|
||||
completed: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const XCMConfigurationWizard: React.FC<XCMConfigurationWizardProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Wizard state
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [steps, setSteps] = useState<Record<number, StepStatus>>({
|
||||
1: { completed: false },
|
||||
2: { completed: false },
|
||||
3: { completed: false },
|
||||
4: { completed: false },
|
||||
5: { completed: false },
|
||||
6: { completed: false },
|
||||
});
|
||||
|
||||
// Step 1: Reserve ParaId
|
||||
const [relayChain, setRelayChain] = useState<RelayChain>('westend');
|
||||
const [reservedParaId, setReservedParaId] = useState<number | null>(null);
|
||||
const [reserving, setReserving] = useState(false);
|
||||
|
||||
// Step 2: Generate Artifacts
|
||||
const [artifacts, setArtifacts] = useState<ChainArtifacts | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Step 3: Register Parachain
|
||||
const [genesisFile, setGenesisFile] = useState<File | null>(null);
|
||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [registrationTxHash, setRegistrationTxHash] = useState<string>('');
|
||||
|
||||
// Step 4: HRMP Channels
|
||||
const [openingChannels, setOpeningChannels] = useState(false);
|
||||
const [openedChannels, setOpenedChannels] = useState<HRMPChannel[]>([]);
|
||||
|
||||
// Step 5: Foreign Assets
|
||||
const [registeringAssets, setRegisteringAssets] = useState(false);
|
||||
const [registeredAssets, setRegisteredAssets] = useState<RegisteredAsset[]>([]);
|
||||
|
||||
// Step 6: XCM Test
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; balance: string } | null>(null);
|
||||
|
||||
const totalSteps = 6;
|
||||
const progress = (Object.values(steps).filter(s => s.completed).length / totalSteps) * 100;
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCurrentStep(1);
|
||||
setSteps({
|
||||
1: { completed: false },
|
||||
2: { completed: false },
|
||||
3: { completed: false },
|
||||
4: { completed: false },
|
||||
5: { completed: false },
|
||||
6: { completed: false },
|
||||
});
|
||||
setReservedParaId(null);
|
||||
setArtifacts(null);
|
||||
setOpenedChannels([]);
|
||||
setRegisteredAssets([]);
|
||||
setTestResult(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// ========================================
|
||||
// STEP 1: RESERVE PARAID
|
||||
// ========================================
|
||||
const handleReserveParaId = async () => {
|
||||
if (!account || !signer) {
|
||||
toast({
|
||||
title: 'Wallet not connected',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setReserving(true);
|
||||
try {
|
||||
// Connect to relay chain
|
||||
const endpoint = getRelayChainEndpoint(relayChain);
|
||||
const provider = new WsProvider(endpoint);
|
||||
const relayApi = await ApiPromise.create({ provider });
|
||||
|
||||
// Reserve ParaId
|
||||
const paraId = await reserveParaId(relayApi, relayChain, account);
|
||||
setReservedParaId(paraId);
|
||||
|
||||
// Mark step as completed
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
1: { completed: true, data: { paraId, relayChain } },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'ParaId Reserved!',
|
||||
description: `Successfully reserved ParaId ${paraId} on ${relayChain}`,
|
||||
});
|
||||
|
||||
// Auto-advance to next step
|
||||
setCurrentStep(2);
|
||||
|
||||
await relayApi.disconnect();
|
||||
} catch (error) {
|
||||
console.error('Failed to reserve ParaId:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
1: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Reservation Failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to reserve ParaId',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setReserving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// STEP 2: GENERATE CHAIN ARTIFACTS
|
||||
// ========================================
|
||||
const handleGenerateArtifacts = async () => {
|
||||
if (!reservedParaId) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const chainName = `pezkuwichain-${relayChain}`;
|
||||
const artifactData = await generateChainArtifacts(chainName);
|
||||
setArtifacts(artifactData);
|
||||
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
2: { completed: true, data: artifactData },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'Artifacts Generated!',
|
||||
description: 'Genesis state and runtime WASM are ready for download',
|
||||
});
|
||||
|
||||
setCurrentStep(3);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate artifacts:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
2: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Generation Failed',
|
||||
description: 'Failed to generate chain artifacts',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// STEP 3: REGISTER PARACHAIN
|
||||
// ========================================
|
||||
const handleRegisterParachain = async () => {
|
||||
if (!reservedParaId || !genesisFile || !wasmFile || !account || !signer) {
|
||||
toast({
|
||||
title: 'Missing Data',
|
||||
description: 'Please upload both genesis and WASM files',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
const endpoint = getRelayChainEndpoint(relayChain);
|
||||
const provider = new WsProvider(endpoint);
|
||||
const relayApi = await ApiPromise.create({ provider });
|
||||
|
||||
const txHash = await registerParachain(relayApi, reservedParaId, genesisFile, wasmFile, account);
|
||||
setRegistrationTxHash(txHash);
|
||||
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
3: { completed: true, data: { txHash, paraId: reservedParaId } },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'Parachain Registered!',
|
||||
description: `ParaId ${reservedParaId} registered on ${relayChain}`,
|
||||
});
|
||||
|
||||
setCurrentStep(4);
|
||||
|
||||
await relayApi.disconnect();
|
||||
} catch (error) {
|
||||
console.error('Failed to register parachain:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
3: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Registration Failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to register parachain',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// STEP 4: OPEN HRMP CHANNELS
|
||||
// ========================================
|
||||
const handleOpenHRMPChannels = async () => {
|
||||
if (!reservedParaId || !account || !signer) return;
|
||||
|
||||
setOpeningChannels(true);
|
||||
try {
|
||||
const endpoint = getRelayChainEndpoint(relayChain);
|
||||
const provider = new WsProvider(endpoint);
|
||||
const relayApi = await ApiPromise.create({ provider });
|
||||
|
||||
// Get Asset Hub ParaId
|
||||
const assetHubParaId = getAssetHubParaId(relayChain);
|
||||
|
||||
// Open channels with Asset Hub
|
||||
const channels = await openHRMPChannels(relayApi, reservedParaId, [assetHubParaId], account);
|
||||
setOpenedChannels(channels);
|
||||
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
4: { completed: true, data: { channels } },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'HRMP Channels Opened!',
|
||||
description: `Opened ${channels.length} channel(s) with Asset Hub`,
|
||||
});
|
||||
|
||||
setCurrentStep(5);
|
||||
|
||||
await relayApi.disconnect();
|
||||
} catch (error) {
|
||||
console.error('Failed to open HRMP channels:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
4: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Channel Opening Failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to open HRMP channels',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setOpeningChannels(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// STEP 5: REGISTER FOREIGN ASSETS
|
||||
// ========================================
|
||||
const handleRegisterAssets = async () => {
|
||||
if (!api || !isApiReady || !account || !signer) return;
|
||||
|
||||
setRegisteringAssets(true);
|
||||
try {
|
||||
// Define foreign assets to register (USDT, DOT, etc.)
|
||||
const foreignAssets: ForeignAsset[] = [
|
||||
{
|
||||
symbol: 'USDT',
|
||||
location: {
|
||||
parents: 1,
|
||||
interior: {
|
||||
X3: [{ Parachain: 1000 }, { PalletInstance: 50 }, { GeneralIndex: 1984 }],
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'Tether USD',
|
||||
symbol: 'USDT',
|
||||
decimals: 6,
|
||||
minBalance: '1000',
|
||||
},
|
||||
},
|
||||
{
|
||||
symbol: 'DOT',
|
||||
location: {
|
||||
parents: 1,
|
||||
interior: { Here: null },
|
||||
},
|
||||
metadata: {
|
||||
name: 'Polkadot',
|
||||
symbol: 'DOT',
|
||||
decimals: 10,
|
||||
minBalance: '10000000000',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const registered = await registerForeignAssets(api, foreignAssets, account);
|
||||
setRegisteredAssets(registered);
|
||||
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
5: { completed: true, data: { assets: registered } },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'Assets Registered!',
|
||||
description: `Registered ${registered.length} foreign asset(s)`,
|
||||
});
|
||||
|
||||
setCurrentStep(6);
|
||||
} catch (error) {
|
||||
console.error('Failed to register assets:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
5: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Asset Registration Failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to register foreign assets',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setRegisteringAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// STEP 6: TEST XCM TRANSFER
|
||||
// ========================================
|
||||
const handleTestXCMTransfer = async () => {
|
||||
if (!api || !isApiReady || !account || !signer) return;
|
||||
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testXCMTransfer(api, '1000000', account); // 1 USDT (6 decimals)
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
6: { completed: result.success, data: result },
|
||||
}));
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'XCM Test Successful!',
|
||||
description: `Received ${result.balance} wUSDT`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'XCM Test Failed',
|
||||
description: result.error || 'Test transfer failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to test XCM transfer:', error);
|
||||
setSteps(prev => ({
|
||||
...prev,
|
||||
6: { completed: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Test Failed',
|
||||
description: error instanceof Error ? error.message : 'XCM test failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// RENDER STEP CONTENT
|
||||
// ========================================
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Relay Chain</Label>
|
||||
<Select value={relayChain} onValueChange={(value: RelayChain) => setRelayChain(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="westend">Westend (Testnet)</SelectItem>
|
||||
<SelectItem value="rococo">Rococo (Testnet)</SelectItem>
|
||||
<SelectItem value="polkadot">Polkadot (Mainnet)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{reservedParaId && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
ParaId <strong>{reservedParaId}</strong> reserved on {relayChain}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{steps[1].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[1].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleReserveParaId} disabled={reserving || steps[1].completed} className="w-full">
|
||||
{reserving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Reserving ParaId...
|
||||
</>
|
||||
) : steps[1].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
ParaId Reserved
|
||||
</>
|
||||
) : (
|
||||
'Reserve ParaId'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{artifacts && (
|
||||
<div className="space-y-2">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Artifacts generated. Download files for registration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={artifacts.genesisPath} download>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Genesis ({artifacts.genesisSize} bytes)
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={artifacts.wasmPath} download>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
WASM ({artifacts.wasmSize} bytes)
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps[2].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[2].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleGenerateArtifacts} disabled={generating || steps[2].completed} className="w-full">
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating Artifacts...
|
||||
</>
|
||||
) : steps[2].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Artifacts Generated
|
||||
</>
|
||||
) : (
|
||||
'Generate Chain Artifacts'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Genesis State</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".hex,.txt"
|
||||
onChange={(e) => setGenesisFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Runtime WASM</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".wasm"
|
||||
onChange={(e) => setWasmFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{registrationTxHash && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Registered! TX: <code className="text-xs">{registrationTxHash.slice(0, 20)}...</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{steps[3].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[3].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleRegisterParachain}
|
||||
disabled={!genesisFile || !wasmFile || registering || steps[3].completed}
|
||||
className="w-full"
|
||||
>
|
||||
{registering ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registering Parachain...
|
||||
</>
|
||||
) : steps[3].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Parachain Registered
|
||||
</>
|
||||
) : (
|
||||
'Register Parachain'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Opening HRMP channels with Asset Hub (ParaId {getAssetHubParaId(relayChain)})
|
||||
</p>
|
||||
|
||||
{openedChannels.length > 0 && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Opened {openedChannels.length} channel(s):
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{openedChannels.map((ch, idx) => (
|
||||
<li key={idx}>
|
||||
{ch.sender} → {ch.receiver} (ID: {ch.channelId.slice(0, 10)}...)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{steps[4].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[4].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleOpenHRMPChannels} disabled={openingChannels || steps[4].completed} className="w-full">
|
||||
{openingChannels ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Opening HRMP Channels...
|
||||
</>
|
||||
) : steps[4].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Channels Opened
|
||||
</>
|
||||
) : (
|
||||
'Open HRMP Channels'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 5:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Register foreign assets: USDT, DOT, and other cross-chain tokens
|
||||
</p>
|
||||
|
||||
{registeredAssets.length > 0 && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Registered {registeredAssets.length} asset(s):
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{registeredAssets.map((asset, idx) => (
|
||||
<li key={idx}>
|
||||
{asset.symbol} (Asset ID: {asset.assetId})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{steps[5].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[5].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleRegisterAssets} disabled={registeringAssets || steps[5].completed} className="w-full">
|
||||
{registeringAssets ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registering Assets...
|
||||
</>
|
||||
) : steps[5].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Assets Registered
|
||||
</>
|
||||
) : (
|
||||
'Register Foreign Assets'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 6:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Test XCM transfer from Asset Hub to verify bridge functionality
|
||||
</p>
|
||||
|
||||
{testResult && (
|
||||
<Alert variant={testResult.success ? 'default' : 'destructive'}>
|
||||
{testResult.success ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
<AlertDescription>
|
||||
{testResult.success
|
||||
? `Test successful! Balance: ${testResult.balance} wUSDT`
|
||||
: `Test failed: ${testResult.error}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{steps[6].error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{steps[6].error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleTestXCMTransfer} disabled={testing || steps[6].completed} className="w-full">
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Testing XCM Transfer...
|
||||
</>
|
||||
) : steps[6].completed ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
XCM Test Passed
|
||||
</>
|
||||
) : (
|
||||
'Test XCM Transfer'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if all steps are completed
|
||||
const allStepsCompleted = Object.values(steps).every(s => s.completed);
|
||||
|
||||
// Handle Finish Configuration
|
||||
const handleFinishConfiguration = () => {
|
||||
toast({
|
||||
title: 'XCM Configuration Complete!',
|
||||
description: 'Your parachain is fully configured and ready for cross-chain transfers',
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>XCM Configuration Wizard</CardTitle>
|
||||
<CardDescription>
|
||||
Complete parachain setup and cross-chain integration
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="mt-2 text-xs text-muted-foreground text-center">
|
||||
{Object.values(steps).filter(s => s.completed).length} / {totalSteps} steps completed
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Step Navigation */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((step) => (
|
||||
<button
|
||||
key={step}
|
||||
onClick={() => setCurrentStep(step)}
|
||||
className={`flex flex-col items-center gap-1 p-2 rounded-lg transition-colors ${
|
||||
currentStep === step
|
||||
? 'bg-kurdish-green text-white'
|
||||
: steps[step].completed
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{steps[step].completed ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : currentStep === step ? (
|
||||
<Circle className="h-5 w-5 fill-current" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{step}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current Step Content */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Step {currentStep}</Badge>
|
||||
<h3 className="font-semibold">
|
||||
{currentStep === 1 && 'Reserve ParaId'}
|
||||
{currentStep === 2 && 'Generate Chain Artifacts'}
|
||||
{currentStep === 3 && 'Register Parachain'}
|
||||
{currentStep === 4 && 'Open HRMP Channels'}
|
||||
{currentStep === 5 && 'Register Foreign Assets'}
|
||||
{currentStep === 6 && 'Test XCM Transfer'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentStep(Math.max(1, currentStep - 1))}
|
||||
disabled={currentStep === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{allStepsCompleted ? (
|
||||
<Button onClick={handleFinishConfiguration} className="bg-kurdish-green hover:bg-kurdish-green-dark">
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Finish Configuration
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setCurrentStep(Math.min(totalSteps, currentStep + 1))}
|
||||
disabled={currentStep === totalSteps || !steps[currentStep].completed}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Shield, Copy, Check, AlertCircle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [copiedCodes, setCopiedCodes] = useState(false);
|
||||
|
||||
const handleSetup = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('two-factor-auth', {
|
||||
body: { action: 'setup', userId: user?.id }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSecret(data.secret);
|
||||
setBackupCodes(data.backupCodes);
|
||||
setShowSetup(true);
|
||||
|
||||
toast({
|
||||
title: '2FA Setup Started',
|
||||
description: 'Scan the QR code with your authenticator app',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Setup Failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (!verificationCode) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please enter verification code',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke('two-factor-auth', {
|
||||
body: {
|
||||
action: 'enable',
|
||||
userId: user?.id,
|
||||
code: verificationCode
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setIsEnabled(true);
|
||||
setShowSetup(false);
|
||||
|
||||
toast({
|
||||
title: '2FA Enabled',
|
||||
description: 'Your account is now protected with two-factor authentication',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Verification Failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke('two-factor-auth', {
|
||||
body: { action: 'disable', userId: user?.id }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setIsEnabled(false);
|
||||
setSecret('');
|
||||
setBackupCodes([]);
|
||||
|
||||
toast({
|
||||
title: '2FA Disabled',
|
||||
description: 'Two-factor authentication has been disabled',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyBackupCodes = () => {
|
||||
navigator.clipboard.writeText(backupCodes.join('\n'));
|
||||
setCopiedCodes(true);
|
||||
setTimeout(() => setCopiedCodes(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isEnabled && !showSetup && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={handleSetup} disabled={isLoading}>
|
||||
Set Up Two-Factor Authentication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSetup && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">1. Scan QR Code</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Use your authenticator app to scan this QR code or enter the secret manually
|
||||
</p>
|
||||
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
|
||||
{secret}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">2. Save Backup Codes</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Store these codes in a safe place. You can use them to access your account if you lose your device.
|
||||
</p>
|
||||
<div className="bg-muted p-3 rounded space-y-1">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i} className="font-mono text-xs">{code}</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={copyBackupCodes}
|
||||
>
|
||||
{copiedCodes ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{copiedCodes ? 'Copied!' : 'Copy Codes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">3. Verify Setup</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="000000"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button onClick={handleEnable} disabled={isLoading}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEnabled && (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800">
|
||||
Two-factor authentication is enabled for your account
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="destructive" onClick={handleDisable} disabled={isLoading}>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Shield, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface TwoFactorVerifyProps {
|
||||
userId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function TwoFactorVerify({ userId, onSuccess, onCancel }: TwoFactorVerifyProps) {
|
||||
const { toast } = useToast();
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [backupCode, setBackupCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleVerify = async (useBackup: boolean = false) => {
|
||||
const code = useBackup ? backupCode : verificationCode;
|
||||
|
||||
if (!code) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please enter a code',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('two-factor-auth', {
|
||||
body: {
|
||||
action: 'verify',
|
||||
userId,
|
||||
code: useBackup ? undefined : code,
|
||||
backupCode: useBackup ? code : undefined
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: 'Verification Successful',
|
||||
description: 'You have been authenticated',
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
throw new Error(data.error || 'Verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Verification Failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your authentication code to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="authenticator" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="authenticator">Authenticator App</TabsTrigger>
|
||||
<TabsTrigger value="backup">Backup Code</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="authenticator" className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Input
|
||||
placeholder="000000"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl font-mono"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => handleVerify(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Verify
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Enter one of your backup codes
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Input
|
||||
placeholder="Backup code"
|
||||
value={backupCode}
|
||||
onChange={(e) => setBackupCode(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => handleVerify(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Verify
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ExistingCitizenAuth } from './ExistingCitizenAuth';
|
||||
import { NewCitizenApplication } from './NewCitizenApplication';
|
||||
|
||||
interface CitizenshipModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
referrerAddress?: string | null;
|
||||
}
|
||||
|
||||
export const CitizenshipModal: React.FC<CitizenshipModalProps> = ({ isOpen, onClose, referrerAddress }) => {
|
||||
const [activeTab, setActiveTab] = useState<'existing' | 'new'>(referrerAddress ? 'new' : 'existing');
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-2xl">
|
||||
🏛️ Digital Kurdistan Citizenship
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{referrerAddress
|
||||
? 'You have been invited to join Digital Kurdistan! Complete the application below.'
|
||||
: 'Join the Digital Kurdistan State as a citizen or authenticate your existing citizenship'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'existing' | 'new')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="existing">I am Already a Citizen</TabsTrigger>
|
||||
<TabsTrigger value="new">I Want to Become a Citizen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-6">
|
||||
<ExistingCitizenAuth onClose={onClose} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-6">
|
||||
<NewCitizenApplication onClose={onClose} referrerAddress={referrerAddress} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, CheckCircle, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { verifyCitizenNumber } from '@pezkuwi/lib/tiki';
|
||||
import { generateAuthChallenge, signChallenge, verifySignature, saveCitizenSession } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import type { AuthChallenge } from '@pezkuwi/lib/citizenship-workflow';
|
||||
|
||||
interface ExistingCitizenAuthProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ExistingCitizenAuth: React.FC<ExistingCitizenAuthProps> = ({ onClose }) => {
|
||||
const { api, isApiReady, selectedAccount, connectWallet } = usePezkuwi();
|
||||
|
||||
const [citizenNumber, setCitizenNumber] = useState('');
|
||||
const [step, setStep] = useState<'input' | 'verifying' | 'signing' | 'success' | 'error'>('input');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [challenge, setChallenge] = useState<AuthChallenge | null>(null);
|
||||
|
||||
const handleVerifyNFT = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setError('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!citizenNumber.trim()) {
|
||||
setError('Please enter your Citizen Number');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setStep('verifying');
|
||||
|
||||
try {
|
||||
// Verify Citizen Number
|
||||
const isValid = await verifyCitizenNumber(api, citizenNumber, selectedAccount.address);
|
||||
|
||||
if (!isValid) {
|
||||
setError(`Invalid Citizen Number or it doesn't match your wallet`);
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate challenge for signature
|
||||
const authChallenge = generateAuthChallenge(citizenNumber);
|
||||
setChallenge(authChallenge);
|
||||
setStep('signing');
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Verification error:', err);
|
||||
setError('Failed to verify Citizen Number');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignChallenge = async () => {
|
||||
if (!selectedAccount || !challenge) {
|
||||
setError('Missing authentication data');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Sign the challenge
|
||||
const signature = await signChallenge(selectedAccount, challenge);
|
||||
|
||||
// Verify signature (self-verification for demonstration)
|
||||
const isValid = await verifySignature(signature, challenge, selectedAccount.address);
|
||||
|
||||
if (!isValid) {
|
||||
setError('Signature verification failed');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save session
|
||||
const session = {
|
||||
tikiNumber: citizenNumber,
|
||||
walletAddress: selectedAccount.address,
|
||||
sessionToken: signature, // In production, use proper JWT
|
||||
lastAuthenticated: Date.now(),
|
||||
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
|
||||
};
|
||||
|
||||
await saveCitizenSession(session);
|
||||
|
||||
setStep('success');
|
||||
|
||||
// Redirect to citizens page after 2 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.href = '/citizens';
|
||||
}, 2000);
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Signature error:', err);
|
||||
setError('Failed to sign authentication challenge');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
try {
|
||||
await connectWallet();
|
||||
} catch {
|
||||
setError('Failed to connect wallet');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-cyan-500" />
|
||||
Authenticate as Citizen
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Citizen Number from your Dashboard to authenticate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Step 1: Enter Citizen Number */}
|
||||
{step === 'input' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="citizenNumber">Citizen Number</Label>
|
||||
<Input
|
||||
id="citizenNumber"
|
||||
placeholder="e.g., #42-0-123456"
|
||||
value={citizenNumber}
|
||||
onChange={(e) => setCitizenNumber(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleVerifyNFT()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your full Citizen Number from your Dashboard (format: #CollectionID-ItemID-6digits)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!selectedAccount ? (
|
||||
<Button onClick={handleConnectWallet} className="w-full">
|
||||
Connect Wallet First
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleVerifyNFT} className="w-full">
|
||||
Verify Citizen Number
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: Verifying */}
|
||||
{step === 'verifying' && (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-cyan-500" />
|
||||
<p className="text-sm text-muted-foreground">Verifying Citizen Number on blockchain...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Sign Challenge */}
|
||||
{step === 'signing' && (
|
||||
<>
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
NFT ownership verified! Now sign to prove you control this wallet.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<p className="text-sm font-medium">Authentication Challenge:</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{challenge?.nonce}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSignChallenge} className="w-full">
|
||||
Sign Message to Authenticate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Success */}
|
||||
{step === 'success' && (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<CheckCircle className="h-16 w-16 text-green-500" />
|
||||
<h3 className="text-lg font-semibold">Authentication Successful!</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Welcome back, Citizen #{citizenNumber}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Redirecting to citizen dashboard...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<Button onClick={() => { setStep('input'); setError(null); }} variant="outline" className="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Info */}
|
||||
<Card className="bg-cyan-500/10 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2 text-sm">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Security Information
|
||||
</h4>
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
<li>• Your NFT number is cryptographically verified on-chain</li>
|
||||
<li>• Signature proves you control the wallet without revealing private keys</li>
|
||||
<li>• Session expires after 24 hours for your security</li>
|
||||
<li>• No personal data is transmitted or stored on-chain</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,668 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2, AlertTriangle, CheckCircle, User, Users as UsersIcon, MapPin, Briefcase, Mail, Check, X, AlertCircle } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import type { CitizenshipData, Region, MaritalStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { generateCommitmentHash, generateNullifierHash, encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
|
||||
|
||||
interface NewCitizenApplicationProps {
|
||||
onClose: () => void;
|
||||
referrerAddress?: string | null;
|
||||
}
|
||||
|
||||
type FormData = Omit<CitizenshipData, 'walletAddress' | 'timestamp'>;
|
||||
|
||||
export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ onClose, referrerAddress }) => {
|
||||
const { api, isApiReady, selectedAccount, connectWallet } = usePezkuwi();
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [waitingForApproval, setWaitingForApproval] = useState(false);
|
||||
const [kycApproved, setKycApproved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [applicationHash, setApplicationHash] = useState<string>('');
|
||||
|
||||
const maritalStatus = watch('maritalStatus');
|
||||
const childrenCount = watch('childrenCount');
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
setError('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Confirming citizenship application (self-confirmation)...');
|
||||
|
||||
// Call confirm_citizenship() extrinsic - self-confirmation for Welati Tiki
|
||||
const tx = api.tx.identityKyc.confirmCitizenship();
|
||||
|
||||
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, events, dispatchError }) => {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
if (process.env.NODE_ENV !== 'production') console.error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
|
||||
setError(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.error(dispatchError.toString());
|
||||
setError(dispatchError.toString());
|
||||
}
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Citizenship confirmed successfully!');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Block hash:', status.asInBlock || status.asFinalized);
|
||||
|
||||
// Check for CitizenshipConfirmed event
|
||||
events.forEach(({ event }) => {
|
||||
if (event.section === 'identityKyc' && event.method === 'CitizenshipConfirmed') {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📢 CitizenshipConfirmed event detected');
|
||||
setKycApproved(true);
|
||||
setWaitingForApproval(false);
|
||||
|
||||
// Redirect to citizen dashboard after 2 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.href = '/dashboard';
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
setConfirming(false);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Approval error:', err);
|
||||
setError((err as Error).message || 'Failed to approve application');
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
// Cancel/withdraw the application - simply close modal and go back
|
||||
// No blockchain interaction needed - application will remain Pending until confirmed or admin-rejected
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Canceling citizenship application (no blockchain interaction)');
|
||||
onClose();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// Check KYC status on mount
|
||||
useEffect(() => {
|
||||
const checkKycStatus = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingStatus(true);
|
||||
try {
|
||||
const status = await getKycStatus(api, selectedAccount.address);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Current KYC Status:', status);
|
||||
|
||||
if (status === 'Approved') {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('KYC already approved! Redirecting to dashboard...');
|
||||
setKycApproved(true);
|
||||
|
||||
// Redirect to dashboard after 2 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.href = '/dashboard';
|
||||
}, 2000);
|
||||
} else if (status === 'Pending') {
|
||||
// If pending, show the waiting screen
|
||||
setWaitingForApproval(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error checking KYC status:', err);
|
||||
} finally {
|
||||
setCheckingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkKycStatus();
|
||||
}, [api, isApiReady, selectedAccount, onClose]);
|
||||
|
||||
// Subscribe to KYC approval events
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedAccount || !waitingForApproval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Setting up KYC approval listener for:', selectedAccount.address);
|
||||
|
||||
const unsubscribe = subscribeToKycApproval(
|
||||
api,
|
||||
selectedAccount.address,
|
||||
() => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('KYC Approved! Redirecting to dashboard...');
|
||||
setKycApproved(true);
|
||||
setWaitingForApproval(false);
|
||||
|
||||
// Redirect to citizen dashboard after 2 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.href = '/dashboard';
|
||||
}, 2000);
|
||||
},
|
||||
(error) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('KYC approval subscription error:', error);
|
||||
setError(`Failed to monitor approval status: ${error}`);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [api, isApiReady, selectedAccount, waitingForApproval, onClose]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setError('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreed) {
|
||||
setError('Please agree to the terms');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Check KYC status before submitting
|
||||
const currentStatus = await getKycStatus(api, selectedAccount.address);
|
||||
|
||||
if (currentStatus === 'Approved') {
|
||||
setError('Your KYC has already been approved! Redirecting to dashboard...');
|
||||
setKycApproved(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.href = '/dashboard';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStatus === 'Pending') {
|
||||
setError('You already have a pending KYC application. Please wait for admin approval.');
|
||||
setWaitingForApproval(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: Referral initiation must be done by the REFERRER before the referee does KYC
|
||||
// The referrer calls api.tx.referral.initiateReferral(refereeAddress) from InviteUserModal
|
||||
// Here we just use the referrerAddress in the citizenship data if provided
|
||||
if (referrerAddress) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`KYC application with referrer: ${referrerAddress}`);
|
||||
}
|
||||
|
||||
// Prepare complete citizenship data
|
||||
const citizenshipData: CitizenshipData = {
|
||||
...data,
|
||||
walletAddress: selectedAccount.address,
|
||||
timestamp: Date.now(),
|
||||
referralCode: data.referralCode || FOUNDER_ADDRESS // Auto-assign to founder if empty
|
||||
};
|
||||
|
||||
// Generate commitment and nullifier hashes
|
||||
const commitmentHash = await generateCommitmentHash(citizenshipData);
|
||||
const nullifierHash = await generateNullifierHash(selectedAccount.address, citizenshipData.timestamp);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Commitment Hash:', commitmentHash);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Nullifier Hash:', nullifierHash);
|
||||
|
||||
// Encrypt data
|
||||
const encryptedData = await encryptData(citizenshipData, selectedAccount.address);
|
||||
|
||||
// Save to local storage (backup)
|
||||
await saveLocalCitizenshipData(citizenshipData, selectedAccount.address);
|
||||
|
||||
// Upload to IPFS
|
||||
const ipfsCid = await uploadToIPFS(encryptedData);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('IPFS CID:', ipfsCid);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('IPFS CID type:', typeof ipfsCid);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('IPFS CID value:', JSON.stringify(ipfsCid));
|
||||
|
||||
// Ensure ipfsCid is a string
|
||||
const cidString = String(ipfsCid);
|
||||
if (!cidString || cidString === 'undefined' || cidString === '[object Object]') {
|
||||
throw new Error(`Invalid IPFS CID: ${cidString}`);
|
||||
}
|
||||
|
||||
// Submit to blockchain
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Submitting KYC application to blockchain...');
|
||||
const result = await submitKycApplication(
|
||||
api,
|
||||
selectedAccount,
|
||||
citizenshipData.fullName,
|
||||
citizenshipData.email,
|
||||
cidString,
|
||||
`Citizenship application for ${citizenshipData.fullName}`
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to submit KYC application to blockchain');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ KYC application submitted to blockchain');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Block hash:', result.blockHash);
|
||||
|
||||
// Save block hash for display
|
||||
if (result.blockHash) {
|
||||
setApplicationHash(result.blockHash.slice(0, 16) + '...');
|
||||
}
|
||||
|
||||
// Move to waiting for approval state
|
||||
setSubmitted(true);
|
||||
setSubmitting(false);
|
||||
setWaitingForApproval(true);
|
||||
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Submission error:', err);
|
||||
setError('Failed to submit citizenship application');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedAccount) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Wallet Required</CardTitle>
|
||||
<CardDescription>
|
||||
You need to connect your wallet to apply for citizenship
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={connectWallet} className="w-full">
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// KYC Approved - Success state
|
||||
if (kycApproved) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<CheckCircle className="h-16 w-16 text-green-500 animate-pulse" />
|
||||
<h3 className="text-lg font-semibold text-center text-green-500">KYC Approved!</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||
Congratulations! Your citizenship application has been approved. Redirecting to citizen dashboard...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Waiting for self-confirmation
|
||||
if (waitingForApproval) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
|
||||
{/* Icon */}
|
||||
<div className="relative">
|
||||
<div className="h-24 w-24 rounded-full border-4 border-primary/20 flex items-center justify-center">
|
||||
<CheckCircle className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Confirm Your Citizenship Application</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Your application has been submitted to the blockchain. Please review and confirm your identity to mint your Citizen NFT (Welati Tiki).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status steps */}
|
||||
<div className="w-full max-w-md space-y-3 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
||||
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Data Encrypted</p>
|
||||
<p className="text-xs text-muted-foreground">Your KYC data has been encrypted and stored on IPFS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
||||
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Blockchain Submitted</p>
|
||||
<p className="text-xs text-muted-foreground">Transaction hash: {applicationHash || 'Processing...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Awaiting Your Confirmation</p>
|
||||
<p className="text-xs text-muted-foreground">Confirm or reject your application below</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3 w-full max-w-md pt-4">
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={confirming}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
disabled={confirming}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Rejecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Reject
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full max-w-md">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={onClose} className="mt-2">
|
||||
Close
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial submission success (before blockchain confirmation)
|
||||
if (submitted && !waitingForApproval) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<Loader2 className="h-16 w-16 text-cyan-500 animate-spin" />
|
||||
<h3 className="text-lg font-semibold text-center">Processing Application...</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||
Encrypting your data and submitting to the blockchain. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Personal Identity Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Nasnameya Kesane (Personal Identity)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fullName">Navê Te (Your Full Name) *</Label>
|
||||
<Input {...register('fullName', { required: true })} placeholder="e.g., Berzê Ronahî" />
|
||||
{errors.fullName && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fatherName">Navê Bavê Te (Father's Name) *</Label>
|
||||
<Input {...register('fatherName', { required: true })} placeholder="e.g., Şêrko" />
|
||||
{errors.fatherName && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grandfatherName">Navê Bavkalê Te (Grandfather's Name) *</Label>
|
||||
<Input {...register('grandfatherName', { required: true })} placeholder="e.g., Welat" />
|
||||
{errors.grandfatherName && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motherName">Navê Dayika Te (Mother's Name) *</Label>
|
||||
<Input {...register('motherName', { required: true })} placeholder="e.g., Gula" />
|
||||
{errors.motherName && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tribal Affiliation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Eşîra Te (Tribal Affiliation)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tribe">Eşîra Te (Your Tribe) *</Label>
|
||||
<Input {...register('tribe', { required: true })} placeholder="e.g., Barzanî, Soran, Hewramî..." />
|
||||
{errors.tribe && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Family Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
Rewşa Malbatê (Family Status)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Zewicî / Nezewicî (Married / Unmarried) *</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => setValue('maritalStatus', value as MaritalStatus)}
|
||||
defaultValue="nezewici"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="zewici" id="married" />
|
||||
<Label htmlFor="married">Zewicî (Married)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="nezewici" id="unmarried" />
|
||||
<Label htmlFor="unmarried">Nezewicî (Unmarried)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{maritalStatus === 'zewici' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="childrenCount">Hejmara Zarokan (Number of Children)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
{...register('childrenCount', { valueAsNumber: true })}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{childrenCount && childrenCount > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>Navên Zarokan (Children's Names)</Label>
|
||||
{Array.from({ length: childrenCount }).map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
{...register(`children.${i}.name` as const)}
|
||||
placeholder={`Zaroka ${i + 1} - Nav`}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
{...register(`children.${i}.birthYear` as const, { valueAsNumber: true })}
|
||||
placeholder="Sala Dayikbûnê"
|
||||
min="1900"
|
||||
max={new Date().getFullYear()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Geographic Origin */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Herêma Te (Your Region)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="region">Ji Kuderê yî? (Where are you from?) *</Label>
|
||||
<Select onValueChange={(value) => setValue('region', value as Region)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Herêmeke hilbijêre (Select a region)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bakur">Bakur (North - Turkey/Türkiye)</SelectItem>
|
||||
<SelectItem value="basur">Başûr (South - Iraq)</SelectItem>
|
||||
<SelectItem value="rojava">Rojava (West - Syria)</SelectItem>
|
||||
<SelectItem value="rojhelat">Rojhilat (East - Iran)</SelectItem>
|
||||
<SelectItem value="kurdistan_a_sor">Kurdistan a Sor (Red Kurdistan - Armenia/Azerbaijan)</SelectItem>
|
||||
<SelectItem value="diaspora">Diaspora (Living Abroad)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.region && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact & Profession */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
Têkilî û Pîşe (Contact & Profession)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
E-mail *
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
{...register('email', { required: true, pattern: /^\S+@\S+$/i })}
|
||||
placeholder="example@email.com"
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-red-500">Valid email required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profession">Pîşeya Te (Your Profession) *</Label>
|
||||
<Input {...register('profession', { required: true })} placeholder="e.g., Mamosta, Bijîşk, Xebatkar..." />
|
||||
{errors.profession && <p className="text-xs text-red-500">Required</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Referral */}
|
||||
<Card className="bg-purple-500/10 border-purple-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Koda Referral (Referral Code - Optional)</CardTitle>
|
||||
<CardDescription>
|
||||
If you were invited by another citizen, enter their referral code
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input {...register('referralCode')} placeholder="Referral code (optional)" className="placeholder:text-gray-500 placeholder:opacity-50" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If empty, you will be automatically linked to the Founder (Satoshi Qazi Muhammed)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terms Agreement */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox id="terms" checked={agreed} onCheckedChange={(checked) => setAgreed(checked as boolean)} />
|
||||
<Label htmlFor="terms" className="text-sm leading-relaxed cursor-pointer">
|
||||
Ez pejirandim ku daneyên min bi awayekî ewle (ZK-proof) tên hilanîn û li ser blockchain-ê hash-a wan tê tomarkirin.
|
||||
<br />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(I agree that my data is securely stored with ZK-proof and only its hash is recorded on the blockchain)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={submitting || !agreed} className="w-full" size="lg">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Şandina Daxwazê...
|
||||
</>
|
||||
) : (
|
||||
'Şandina Daxwazê (Submit Application)'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Loader2, ThumbsUp, ThumbsDown, Vote } from 'lucide-react';
|
||||
|
||||
interface Proposal {
|
||||
hash: string;
|
||||
proposalIndex: number;
|
||||
threshold: number;
|
||||
ayes: string[];
|
||||
nays: string[];
|
||||
end: number;
|
||||
call?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function CommissionProposalsCard() {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [voting, setVoting] = useState<string | null>(null);
|
||||
const [isCommissionMember, setIsCommissionMember] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) return;
|
||||
checkMembership();
|
||||
loadProposals();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
const checkMembership = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
setIsCommissionMember(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await api.query.dynamicCommissionCollective.members();
|
||||
const memberList = members.toJSON() as string[];
|
||||
setIsCommissionMember(memberList.includes(selectedAccount.address));
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error checking membership:', error);
|
||||
setIsCommissionMember(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProposals = async () => {
|
||||
if (!api || !isApiReady) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const proposalHashes = await api.query.dynamicCommissionCollective.proposals();
|
||||
const proposalList: Proposal[] = [];
|
||||
|
||||
for (let i = 0; i < proposalHashes.length; i++) {
|
||||
const hash = proposalHashes[i];
|
||||
const voting = await api.query.dynamicCommissionCollective.voting(hash);
|
||||
|
||||
if (!voting.isEmpty) {
|
||||
const voteData = voting.unwrap();
|
||||
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(hash);
|
||||
let proposalCall = null;
|
||||
|
||||
if (!proposalOption.isEmpty) {
|
||||
proposalCall = proposalOption.unwrap();
|
||||
}
|
||||
|
||||
// Get the actual proposal index from the chain
|
||||
const proposalIndex = (voteData as Record<string, unknown>).index?.toNumber() || i;
|
||||
|
||||
proposalList.push({
|
||||
hash: hash.toHex(),
|
||||
proposalIndex: proposalIndex,
|
||||
threshold: voteData.threshold.toNumber(),
|
||||
ayes: voteData.ayes.map((a: { toString: () => string }) => a.toString()),
|
||||
nays: voteData.nays.map((n: { toString: () => string }) => n.toString()),
|
||||
end: voteData.end.toNumber(),
|
||||
call: proposalCall?.toHuman(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProposals(proposalList);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading proposals:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (proposal: Proposal, approve: boolean) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setVoting(proposal.hash);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
const tx = api.tx.dynamicCommissionCollective.vote(
|
||||
proposal.hash,
|
||||
proposal.proposalIndex,
|
||||
approve
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Vote Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const executedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
|
||||
);
|
||||
|
||||
const votedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Voted'
|
||||
);
|
||||
|
||||
if (executedEvent) {
|
||||
toast({
|
||||
title: 'Proposal Passed!',
|
||||
description: 'Threshold reached and executed. KYC approved!',
|
||||
});
|
||||
} else if (votedEvent) {
|
||||
toast({
|
||||
title: 'Vote Recorded',
|
||||
description: `Your ${approve ? 'AYE' : 'NAY'} vote has been recorded`,
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => loadProposals(), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to vote',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setVoting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (proposal: Proposal) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Wallet Not Connected',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setVoting(proposal.hash);
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Get proposal length bound
|
||||
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(proposal.hash);
|
||||
const proposalCall = proposalOption.unwrap();
|
||||
const lengthBound = proposalCall.encodedLength;
|
||||
|
||||
const tx = api.tx.dynamicCommissionCollective.close(
|
||||
proposal.hash,
|
||||
proposal.proposalIndex,
|
||||
{
|
||||
refTime: 1_000_000_000_000, // 1 trillion for ref time
|
||||
proofSize: 64 * 1024, // 64 KB for proof size
|
||||
},
|
||||
lengthBound
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Execute Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const executedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
|
||||
);
|
||||
|
||||
const closedEvent = events.find(({ event }) =>
|
||||
event.section === 'dynamicCommissionCollective' && event.method === 'Closed'
|
||||
);
|
||||
|
||||
if (executedEvent) {
|
||||
const eventData = executedEvent.event.data.toHuman();
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Proposal executed');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Execute event data:', eventData);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Result:', eventData);
|
||||
|
||||
// Check if execution was successful
|
||||
const result = eventData[eventData.length - 1]; // Last parameter is usually the result
|
||||
if (result && typeof result === 'object' && 'Err' in result) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Execution failed:', result.Err);
|
||||
toast({
|
||||
title: 'Execution Failed',
|
||||
description: `Proposal closed but execution failed: ${JSON.stringify(result.Err)}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Proposal Executed!',
|
||||
description: 'KYC approved and NFT minted successfully!',
|
||||
});
|
||||
}
|
||||
} else if (closedEvent) {
|
||||
toast({
|
||||
title: 'Proposal Closed',
|
||||
description: 'Proposal has been closed',
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
toast({
|
||||
title: 'Transaction Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to submit transaction',
|
||||
variant: 'destructive',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => loadProposals(), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to execute proposal',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setVoting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCommissionMember) {
|
||||
return null; // Don't show card if not a commission member
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Commission Proposals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
<span>Loading proposals...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (proposals.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Commission Proposals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-muted-foreground py-4">No active proposals</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Commission Proposals ({proposals.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{proposals.map((proposal) => {
|
||||
const progress = (proposal.ayes.length / proposal.threshold) * 100;
|
||||
const hasVoted = proposal.ayes.includes(selectedAccount?.address || '') ||
|
||||
proposal.nays.includes(selectedAccount?.address || '');
|
||||
|
||||
return (
|
||||
<div key={proposal.hash} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium">Proposal #{proposal.proposalIndex}</p>
|
||||
<p className="text-sm text-muted-foreground">KYC Approval</p>
|
||||
</div>
|
||||
<Badge variant={progress >= 100 ? 'default' : 'secondary'} className={progress >= 100 ? 'bg-green-600' : ''}>
|
||||
{progress >= 100 ? 'PASSED' : `${progress.toFixed(0)}%`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
{proposal.ayes.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
{proposal.nays.length}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">/ {proposal.threshold}</span>
|
||||
</div>
|
||||
|
||||
{progress >= 100 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleExecute(proposal)}
|
||||
disabled={voting === proposal.hash}
|
||||
className="bg-blue-600 hover:bg-blue-700 w-full"
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>Execute Proposal</>
|
||||
)}
|
||||
</Button>
|
||||
) : hasVoted ? (
|
||||
<p className="text-sm text-green-600">✓ You already voted</p>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleVote(proposal, true)}
|
||||
disabled={voting === proposal.hash}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
Aye
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleVote(proposal, false)}
|
||||
disabled={voting === proposal.hash}
|
||||
>
|
||||
{voting === proposal.hash ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
Nay
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CheckCircle, Info, TrendingUp, Users, Award } from 'lucide-react';
|
||||
|
||||
const DelegateProfile: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isDelegate, setIsDelegate] = useState(false);
|
||||
const [profileData, setProfileData] = useState({
|
||||
statement: '',
|
||||
expertise: [],
|
||||
commitments: '',
|
||||
website: '',
|
||||
twitter: '',
|
||||
acceptingDelegations: true,
|
||||
minDelegation: '100',
|
||||
maxDelegation: '100000'
|
||||
});
|
||||
|
||||
const expertiseOptions = [
|
||||
'Treasury Management',
|
||||
'Technical Development',
|
||||
'Community Building',
|
||||
'Governance Design',
|
||||
'Security',
|
||||
'Economics',
|
||||
'Marketing',
|
||||
'Legal'
|
||||
];
|
||||
|
||||
const handleBecomeDelegate = () => {
|
||||
setIsDelegate(true);
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Becoming a delegate with:', profileData);
|
||||
};
|
||||
|
||||
if (!isDelegate) {
|
||||
return (
|
||||
<Card className="border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('delegation.becomeDelegate')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('delegation.becomeDelegateDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert className="border-blue-200 bg-blue-50 text-gray-900">
|
||||
<Info className="w-4 h-4 text-gray-900" />
|
||||
<AlertDescription className="text-gray-900">
|
||||
{t('delegation.delegateRequirements')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<TrendingUp className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||
<h4 className="font-semibold mb-1">{t('delegation.buildReputation')}</h4>
|
||||
<p className="text-sm text-gray-600">{t('delegation.buildReputationDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 text-yellow-600" />
|
||||
<h4 className="font-semibold mb-1">{t('delegation.earnTrust')}</h4>
|
||||
<p className="text-sm text-gray-600">{t('delegation.earnTrustDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<Award className="w-8 h-8 mx-auto mb-2 text-red-600" />
|
||||
<h4 className="font-semibold mb-1">{t('delegation.getRewards')}</h4>
|
||||
<p className="text-sm text-gray-600">{t('delegation.getRewardsDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="statement">{t('delegation.delegateStatement')}</Label>
|
||||
<Textarea
|
||||
id="statement"
|
||||
placeholder={t('delegation.statementPlaceholder')}
|
||||
value={profileData.statement}
|
||||
onChange={(e) => setProfileData({...profileData, statement: e.target.value})}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('delegation.expertise')}</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{expertiseOptions.map((option) => (
|
||||
<label key={option} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProfileData({
|
||||
...profileData,
|
||||
expertise: [...profileData.expertise, option]
|
||||
});
|
||||
} else {
|
||||
setProfileData({
|
||||
...profileData,
|
||||
expertise: profileData.expertise.filter(e => e !== option)
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="commitments">{t('delegation.commitments')}</Label>
|
||||
<Textarea
|
||||
id="commitments"
|
||||
placeholder={t('delegation.commitmentsPlaceholder')}
|
||||
value={profileData.commitments}
|
||||
onChange={(e) => setProfileData({...profileData, commitments: e.target.value})}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="website">{t('delegation.website')}</Label>
|
||||
<Input
|
||||
id="website"
|
||||
placeholder="https://..."
|
||||
value={profileData.website}
|
||||
onChange={(e) => setProfileData({...profileData, website: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="twitter">{t('delegation.twitter')}</Label>
|
||||
<Input
|
||||
id="twitter"
|
||||
placeholder="@username"
|
||||
value={profileData.twitter}
|
||||
onChange={(e) => setProfileData({...profileData, twitter: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="minDelegation">{t('delegation.minDelegation')}</Label>
|
||||
<Input
|
||||
id="minDelegation"
|
||||
type="number"
|
||||
placeholder="Min HEZ"
|
||||
value={profileData.minDelegation}
|
||||
onChange={(e) => setProfileData({...profileData, minDelegation: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxDelegation">{t('delegation.maxDelegation')}</Label>
|
||||
<Input
|
||||
id="maxDelegation"
|
||||
type="number"
|
||||
placeholder="Max HEZ"
|
||||
value={profileData.maxDelegation}
|
||||
onChange={(e) => setProfileData({...profileData, maxDelegation: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="accepting">{t('delegation.acceptingDelegations')}</Label>
|
||||
<p className="text-sm text-gray-600">{t('delegation.acceptingDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="accepting"
|
||||
checked={profileData.acceptingDelegations}
|
||||
onCheckedChange={(checked) =>
|
||||
setProfileData({...profileData, acceptingDelegations: checked})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleBecomeDelegate}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
disabled={!profileData.statement || profileData.expertise.length === 0}
|
||||
>
|
||||
{t('delegation.activateDelegate')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t('delegation.yourDelegateProfile')}
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="border-green-200 bg-green-50 mb-6 text-gray-900">
|
||||
<CheckCircle className="w-4 h-4 text-gray-900" />
|
||||
<AlertDescription className="text-gray-900">
|
||||
{t('delegation.delegateActive')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">0</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.delegators')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">0 HEZ</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.totalReceived')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-red-600">0%</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.successRate')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">{t('delegation.yourStatement')}</h4>
|
||||
<p className="text-gray-700">{profileData.statement}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">{t('delegation.yourExpertise')}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profileData.expertise.map((exp) => (
|
||||
<Badge key={exp} variant="secondary">{exp}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">{t('delegation.delegationLimits')}</h4>
|
||||
<p className="text-gray-700">
|
||||
Min: {profileData.minDelegation} HEZ | Max: {profileData.maxDelegation} HEZ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button variant="outline">
|
||||
{t('delegation.editProfile')}
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
{t('delegation.pauseDelegations')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DelegateProfile;
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Users, TrendingUp, Shield, Clock, ChevronRight, Award, Activity } from 'lucide-react';
|
||||
import DelegateProfile from './DelegateProfile';
|
||||
import { useDelegation } from '@/hooks/useDelegation';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
|
||||
|
||||
const DelegationManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const { delegates, userDelegations, stats, loading, error } = useDelegation(selectedAccount?.address);
|
||||
const [selectedDelegate, setSelectedDelegate] = useState<Record<string, unknown> | null>(null);
|
||||
const [delegationAmount, setDelegationAmount] = useState('');
|
||||
const [delegationPeriod, setDelegationPeriod] = useState('3months');
|
||||
|
||||
// Format token amounts from blockchain units (assuming 12 decimals for HEZ)
|
||||
const formatTokenAmount = (amount: string | number) => {
|
||||
const value = typeof amount === 'string' ? BigInt(amount) : BigInt(amount);
|
||||
return formatNumber(Number(value) / 1e12, 2);
|
||||
};
|
||||
|
||||
const handleDelegate = () => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Delegating:', {
|
||||
delegate: selectedDelegate,
|
||||
amount: delegationAmount,
|
||||
period: delegationPeriod
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading delegation data from blockchain..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Error loading delegation data: {error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('delegation.title')}</h1>
|
||||
<p className="text-gray-600">{t('delegation.description')}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 border-green-500 text-green-700">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Live Blockchain Data
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="border-green-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-green-600" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.activeDelegates}</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.activeDelegates')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-yellow-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-8 h-8 text-yellow-600" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{formatTokenAmount(stats.totalDelegated)}</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.totalDelegated')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-red-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-red-600" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.avgSuccessRate}%</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.avgSuccessRate')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{formatTokenAmount(stats.userDelegated)}</div>
|
||||
<div className="text-sm text-gray-600">{t('delegation.yourDelegated')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="explore" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-green-50 text-gray-900">
|
||||
<TabsTrigger value="explore">{t('delegation.explore')}</TabsTrigger>
|
||||
<TabsTrigger value="my-delegations">{t('delegation.myDelegations')}</TabsTrigger>
|
||||
<TabsTrigger value="delegate-profile">{t('delegation.becomeDelegate')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="explore">
|
||||
<Card className="border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('delegation.topDelegates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{delegates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center text-gray-500">
|
||||
No active delegates found on the blockchain.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
delegates.map((delegate) => (
|
||||
<div
|
||||
key={delegate.id}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedDelegate(delegate)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-green-400 to-green-600 flex items-center justify-center text-white font-bold">
|
||||
{delegate.address.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
{delegate.name}
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
{delegate.successRate}% success
|
||||
</Badge>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-1 font-mono">{delegate.address}</p>
|
||||
<p className="text-sm text-gray-600 mb-2">{delegate.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{delegate.categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary">{cat}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{delegate.reputation} rep
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatTokenAmount(delegate.totalDelegated)} HEZ delegated
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{delegate.delegatorCount} delegators
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Award className="w-3 h-3" />
|
||||
{delegate.activeProposals} active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delegation Form */}
|
||||
{selectedDelegate && (
|
||||
<Card className="mt-6 border-2 border-green-500">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('delegation.delegateTo')} {selectedDelegate.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="amount">{t('delegation.amount')}</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
placeholder="Enter HEZ amount"
|
||||
value={delegationAmount}
|
||||
onChange={(e) => setDelegationAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="period">{t('delegation.period')}</Label>
|
||||
<Select value={delegationPeriod} onValueChange={setDelegationPeriod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1month">1 Month</SelectItem>
|
||||
<SelectItem value="3months">3 Months</SelectItem>
|
||||
<SelectItem value="6months">6 Months</SelectItem>
|
||||
<SelectItem value="1year">1 Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('delegation.categories')}</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{['Treasury', 'Technical', 'Community', 'Governance'].map((cat) => (
|
||||
<label key={cat} className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded" />
|
||||
<span>{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDelegate}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{t('delegation.confirmDelegation')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="my-delegations">
|
||||
<Card className="border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('delegation.yourDelegations')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{userDelegations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center text-gray-500">
|
||||
{selectedAccount
|
||||
? "You haven't delegated any voting power yet."
|
||||
: "Connect your wallet to view your delegations."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
userDelegations.map((delegation) => (
|
||||
<div key={delegation.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-semibold">{delegation.delegate}</h4>
|
||||
<p className="text-xs text-gray-500 font-mono mb-2">{delegation.delegateAddress}</p>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 mt-1">
|
||||
<span>{formatTokenAmount(delegation.amount)} HEZ</span>
|
||||
<Badge variant="secondary">Conviction: {delegation.conviction}x</Badge>
|
||||
{delegation.category && <Badge variant="secondary">{delegation.category}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
{delegation.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" variant="outline">
|
||||
{t('delegation.modify')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
{t('delegation.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="delegate-profile">
|
||||
<DelegateProfile />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DelegationManager;
|
||||
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Plus, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { parseTokenInput, formatTokenBalance, quote } from '@pezkuwi/utils/dex';
|
||||
|
||||
interface AddLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
pool: PoolInfo | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const AddLiquidityModal: React.FC<AddLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
pool,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [amount1Input, setAmount1Input] = useState('');
|
||||
const [amount2Input, setAmount2Input] = useState('');
|
||||
const [slippage, setSlippage] = useState(1); // 1% default
|
||||
|
||||
const [balance1, setBalance1] = useState<string>('0');
|
||||
const [balance2, setBalance2] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes or pool changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pool) {
|
||||
setAmount1Input('');
|
||||
setAmount2Input('');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen, pool]);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || !pool) return;
|
||||
|
||||
try {
|
||||
const balance1Data = await api.query.assets.account(pool.asset1, account);
|
||||
const balance2Data = await api.query.assets.account(pool.asset2, account);
|
||||
|
||||
setBalance1(balance1Data.isSome ? balance1Data.unwrap().balance.toString() : '0');
|
||||
setBalance2(balance2Data.isSome ? balance2Data.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch balances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, pool]);
|
||||
|
||||
// Auto-calculate amount2 when amount1 changes
|
||||
const handleAmount1Change = (value: string) => {
|
||||
setAmount1Input(value);
|
||||
|
||||
if (!pool || !value || parseFloat(value) === 0) {
|
||||
setAmount2Input('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const amount1Raw = parseTokenInput(value, pool.asset1Decimals);
|
||||
const amount2Raw = quote(amount1Raw, pool.reserve2, pool.reserve1);
|
||||
const amount2Display = formatTokenBalance(amount2Raw, pool.asset2Decimals, 6);
|
||||
setAmount2Input(amount2Display);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to calculate amount2:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-calculate amount1 when amount2 changes
|
||||
const handleAmount2Change = (value: string) => {
|
||||
setAmount2Input(value);
|
||||
|
||||
if (!pool || !value || parseFloat(value) === 0) {
|
||||
setAmount1Input('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const amount2Raw = parseTokenInput(value, pool.asset2Decimals);
|
||||
const amount1Raw = quote(amount2Raw, pool.reserve1, pool.reserve2);
|
||||
const amount1Display = formatTokenBalance(amount1Raw, pool.asset1Decimals, 6);
|
||||
setAmount1Input(amount1Display);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to calculate amount1:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateInputs = (): string | null => {
|
||||
if (!pool) return 'No pool selected';
|
||||
if (!amount1Input || !amount2Input) return 'Please enter amounts';
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, pool.asset1Decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, pool.asset2Decimals);
|
||||
|
||||
if (BigInt(amount1Raw) <= BigInt(0) || BigInt(amount2Raw) <= BigInt(0)) {
|
||||
return 'Amounts must be greater than zero';
|
||||
}
|
||||
|
||||
if (BigInt(amount1Raw) > BigInt(balance1)) {
|
||||
return `Insufficient ${pool.asset1Symbol} balance`;
|
||||
}
|
||||
|
||||
if (BigInt(amount2Raw) > BigInt(balance2)) {
|
||||
return `Insufficient ${pool.asset2Symbol} balance`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAddLiquidity = async () => {
|
||||
if (!api || !isApiReady || !signer || !account || !pool) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateInputs();
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, pool.asset1Decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, pool.asset2Decimals);
|
||||
|
||||
// Calculate minimum amounts with slippage tolerance
|
||||
const minAmount1 = (BigInt(amount1Raw) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
const minAmount2 = (BigInt(amount2Raw) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
const tx = api.tx.assetConversion.addLiquidity(
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
amount1Raw,
|
||||
amount2Raw,
|
||||
minAmount1.toString(),
|
||||
minAmount2.toString(),
|
||||
account
|
||||
);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Add liquidity failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !pool) return null;
|
||||
|
||||
const shareOfPool =
|
||||
amount1Input && parseFloat(amount1Input) > 0
|
||||
? (
|
||||
(parseFloat(
|
||||
formatTokenBalance(
|
||||
parseTokenInput(amount1Input, pool.asset1Decimals),
|
||||
pool.asset1Decimals,
|
||||
6
|
||||
)
|
||||
) /
|
||||
(parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6)) +
|
||||
parseFloat(
|
||||
formatTokenBalance(
|
||||
parseTokenInput(amount1Input, pool.asset1Decimals),
|
||||
pool.asset1Decimals,
|
||||
6
|
||||
)
|
||||
))) *
|
||||
100
|
||||
).toFixed(4)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Add Liquidity
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Add liquidity in proportion to the pool's current ratio. You'll receive LP tokens representing your share.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Token 1 Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">{pool.asset1Symbol}</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance1, pool.asset1Decimals, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={amount1Input}
|
||||
onChange={(e) => handleAmount1Change(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleAmount1Change(formatTokenBalance(balance1, pool.asset1Decimals, 6))
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plus Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token 2 Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">{pool.asset2Symbol}</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance2, pool.asset2Decimals, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={amount2Input}
|
||||
onChange={(e) => handleAmount2Change(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleAmount2Change(formatTokenBalance(balance2, pool.asset2Decimals, 6))
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slippage Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Slippage Tolerance</label>
|
||||
<div className="flex gap-2">
|
||||
{[0.5, 1, 2].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSlippage(value)}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
slippage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pool Share Preview */}
|
||||
{amount1Input && amount2Input && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Share of Pool</span>
|
||||
<span className="text-white font-mono">{shareOfPool}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-cyan-400 font-mono">
|
||||
1 {pool.asset1Symbol} ={' '}
|
||||
{(
|
||||
parseFloat(formatTokenBalance(pool.reserve2, pool.asset2Decimals, 6)) /
|
||||
parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6))
|
||||
).toFixed(6)}{' '}
|
||||
{pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Liquidity added successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLiquidity}
|
||||
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Add Liquidity'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Plus, AlertCircle, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KNOWN_TOKENS } from '@/types/dex';
|
||||
import { parseTokenInput, formatTokenBalance } from '@pezkuwi/utils/dex';
|
||||
|
||||
interface CreatePoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const CreatePoolModal: React.FC<CreatePoolModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [asset1Id, setAsset1Id] = useState<number | null>(null);
|
||||
const [asset2Id, setAsset2Id] = useState<number | null>(null);
|
||||
const [amount1Input, setAmount1Input] = useState('');
|
||||
const [amount2Input, setAmount2Input] = useState('');
|
||||
|
||||
const [balance1, setBalance1] = useState<string>('0');
|
||||
const [balance2, setBalance2] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Available tokens
|
||||
const availableTokens = Object.values(KNOWN_TOKENS);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setAsset1Id(null);
|
||||
setAsset2Id(null);
|
||||
setAmount1Input('');
|
||||
setAmount2Input('');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch balances when assets selected
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || asset1Id === null) return;
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔍 Fetching balance for asset', asset1Id, 'account', account);
|
||||
const balance1Data = await api.query.assets.account(asset1Id, account);
|
||||
if (balance1Data.isSome) {
|
||||
const balance = balance1Data.unwrap().balance.toString();
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Balance found for asset', asset1Id, ':', balance);
|
||||
setBalance1(balance);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('⚠️ No balance found for asset', asset1Id);
|
||||
setBalance1('0');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Failed to fetch balance 1:', error);
|
||||
setBalance1('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, asset1Id]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || asset2Id === null) return;
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔍 Fetching balance for asset', asset2Id, 'account', account);
|
||||
const balance2Data = await api.query.assets.account(asset2Id, account);
|
||||
if (balance2Data.isSome) {
|
||||
const balance = balance2Data.unwrap().balance.toString();
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Balance found for asset', asset2Id, ':', balance);
|
||||
setBalance2(balance);
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('⚠️ No balance found for asset', asset2Id);
|
||||
setBalance2('0');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Failed to fetch balance 2:', error);
|
||||
setBalance2('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, asset2Id]);
|
||||
|
||||
const validateInputs = (): string | null => {
|
||||
if (asset1Id === null || asset2Id === null) {
|
||||
return 'Please select both tokens';
|
||||
}
|
||||
|
||||
if (asset1Id === asset2Id) {
|
||||
return 'Cannot create pool with same token';
|
||||
}
|
||||
|
||||
if (!amount1Input || !amount2Input) {
|
||||
return 'Please enter amounts for both tokens';
|
||||
}
|
||||
|
||||
const token1 = KNOWN_TOKENS[asset1Id];
|
||||
const token2 = KNOWN_TOKENS[asset2Id];
|
||||
|
||||
if (!token1 || !token2) {
|
||||
return 'Invalid token selected';
|
||||
}
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, token1.decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, token2.decimals);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('💰 Validation check:', {
|
||||
token1: token1.symbol,
|
||||
amount1Input,
|
||||
amount1Raw,
|
||||
balance1,
|
||||
hasEnough1: BigInt(amount1Raw) <= BigInt(balance1),
|
||||
token2: token2.symbol,
|
||||
amount2Input,
|
||||
amount2Raw,
|
||||
balance2,
|
||||
hasEnough2: BigInt(amount2Raw) <= BigInt(balance2),
|
||||
});
|
||||
|
||||
if (BigInt(amount1Raw) <= BigInt(0) || BigInt(amount2Raw) <= BigInt(0)) {
|
||||
return 'Amounts must be greater than zero';
|
||||
}
|
||||
|
||||
if (BigInt(amount1Raw) > BigInt(balance1)) {
|
||||
return `Insufficient ${token1.symbol} balance`;
|
||||
}
|
||||
|
||||
if (BigInt(amount2Raw) > BigInt(balance2)) {
|
||||
return `Insufficient ${token2.symbol} balance`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreatePool = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateInputs();
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const token1 = KNOWN_TOKENS[asset1Id!];
|
||||
const token2 = KNOWN_TOKENS[asset2Id!];
|
||||
const amount1Raw = parseTokenInput(amount1Input, token1.decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, token2.decimals);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
// Create pool extrinsic
|
||||
const createPoolTx = api.tx.assetConversion.createPool(asset1Id, asset2Id);
|
||||
|
||||
// Add liquidity extrinsic
|
||||
const addLiquidityTx = api.tx.assetConversion.addLiquidity(
|
||||
asset1Id,
|
||||
asset2Id,
|
||||
amount1Raw,
|
||||
amount2Raw,
|
||||
amount1Raw, // min amount1
|
||||
amount2Raw, // min amount2
|
||||
account
|
||||
);
|
||||
|
||||
// Batch transactions
|
||||
const batchTx = api.tx.utility.batchAll([createPoolTx, addLiquidityTx]);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await batchTx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Pool creation failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const token1 = asset1Id !== null ? KNOWN_TOKENS[asset1Id] : null;
|
||||
const token2 = asset2Id !== null ? KNOWN_TOKENS[asset2Id] : null;
|
||||
|
||||
const exchangeRate =
|
||||
amount1Input && amount2Input && parseFloat(amount1Input) > 0
|
||||
? (parseFloat(amount2Input) / parseFloat(amount1Input)).toFixed(6)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Create New Pool
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30 w-fit mt-2">
|
||||
Founder Only
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Token 1 Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Token 1</label>
|
||||
<select
|
||||
value={asset1Id ?? ''}
|
||||
onChange={(e) => setAsset1Id(Number(e.target.value))}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<option value="">Select token...</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id}>
|
||||
{token.symbol} - {token.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{token1 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance1, token1.decimals, 4)} {token1.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount 1 Input */}
|
||||
{token1 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Amount of {token1.symbol}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount1Input}
|
||||
onChange={(e) => setAmount1Input(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plus Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token 2 Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Token 2</label>
|
||||
<select
|
||||
value={asset2Id ?? ''}
|
||||
onChange={(e) => setAsset2Id(Number(e.target.value))}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<option value="">Select token...</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id} disabled={token.id === asset1Id}>
|
||||
{token.symbol} - {token.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{token2 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance2, token2.decimals, 4)} {token2.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount 2 Input */}
|
||||
{token2 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Amount of {token2.symbol}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount2Input}
|
||||
onChange={(e) => setAmount2Input(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange Rate Preview */}
|
||||
{token1 && token2 && amount1Input && amount2Input && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-2">Initial Exchange Rate</div>
|
||||
<div className="text-white font-mono">
|
||||
1 {token1.symbol} = {exchangeRate} {token2.symbol}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Pool created successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreatePool}
|
||||
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Create Pool'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import TokenSwap from '@/components/TokenSwap';
|
||||
import PoolDashboard from '@/components/PoolDashboard';
|
||||
import { CreatePoolModal } from './CreatePoolModal';
|
||||
import { InitializeHezPoolModal } from './InitializeHezPoolModal';
|
||||
import { InitializeUsdtModal } from './InitializeUsdtModal';
|
||||
import { XCMConfigurationWizard } from '@/components/admin/XCMConfigurationWizard';
|
||||
import { ArrowRightLeft, Droplet, Settings } from 'lucide-react';
|
||||
import { isFounderWallet } from '@pezkuwi/utils/auth';
|
||||
|
||||
export const DEXDashboard: React.FC = () => {
|
||||
const { account } = useWallet();
|
||||
const { sudoKey } = usePezkuwi();
|
||||
const [activeTab, setActiveTab] = useState('swap');
|
||||
|
||||
// Admin modal states
|
||||
const [showCreatePoolModal, setShowCreatePoolModal] = useState(false);
|
||||
const [showInitializeHezPoolModal, setShowInitializeHezPoolModal] = useState(false);
|
||||
const [showInitializeUsdtModal, setShowInitializeUsdtModal] = useState(false);
|
||||
const [showXcmBridgeModal, setShowXcmBridgeModal] = useState(false);
|
||||
|
||||
const isFounder = account ? isFounderWallet(account, sudoKey) : false;
|
||||
|
||||
const handleCreatePool = () => {
|
||||
setShowCreatePoolModal(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setShowCreatePoolModal(false);
|
||||
setShowInitializeHezPoolModal(false);
|
||||
setShowInitializeUsdtModal(false);
|
||||
setShowXcmBridgeModal(false);
|
||||
};
|
||||
|
||||
const handleSuccess = async () => {
|
||||
// Pool modals will refresh their own data
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-900/30 via-yellow-900/30 to-red-900/30 border-b border-gray-800 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-green-400 via-yellow-400 to-red-400 bg-clip-text text-transparent">
|
||||
Pezkuwi DEX
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
Decentralized exchange for trading tokens on PezkuwiChain
|
||||
</p>
|
||||
|
||||
{/* Wallet status */}
|
||||
{account && (
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="px-4 py-2 bg-gray-900/80 rounded-lg border border-gray-800">
|
||||
<span className="text-xs text-gray-400">Connected: </span>
|
||||
<span className="text-sm font-mono text-white">
|
||||
{account.slice(0, 6)}...{account.slice(-4)}
|
||||
</span>
|
||||
</div>
|
||||
{isFounder && (
|
||||
<div className="px-4 py-2 bg-green-600/20 border border-green-600/30 rounded-lg">
|
||||
<span className="text-xs text-green-400 font-semibold">
|
||||
FOUNDER ACCESS
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{!account ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mb-4 text-gray-400 text-lg">
|
||||
Please connect your Pezkuwi wallet to use the DEX
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className={`grid w-full ${isFounder ? 'grid-cols-3' : 'grid-cols-2'} gap-2 bg-gray-900/50 p-1 rounded-lg mb-8`}>
|
||||
<TabsTrigger value="swap" className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Swap</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pools" className="flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Pools</span>
|
||||
</TabsTrigger>
|
||||
{isFounder && (
|
||||
<TabsTrigger value="admin" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="swap" className="mt-6">
|
||||
<TokenSwap />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pools" className="mt-6">
|
||||
<PoolDashboard />
|
||||
</TabsContent>
|
||||
|
||||
{isFounder && (
|
||||
<TabsContent value="admin" className="mt-6">
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="p-6 bg-gray-900 border border-blue-900/30 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Token Wrapping</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Convert native HEZ to wrapped wHEZ for use in DEX pools
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowInitializeHezPoolModal(true)}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Wrap HEZ to wHEZ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-green-900/30 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">USDT Token Minting</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Mint wUSDT tokens for testing and liquidity provision
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowInitializeUsdtModal(true)}
|
||||
className="w-full px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Mint wUSDT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-purple-900/30 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">XCM Configuration Wizard</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Complete 6-step parachain setup: Reserve ParaId, generate artifacts, register parachain, open HRMP channels, register foreign assets, and test XCM transfers.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowXcmBridgeModal(true)}
|
||||
className="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Open XCM Configuration Wizard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-gray-800 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Pool Management</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Create new liquidity pools for token pairs on PezkuwiChain
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreatePool}
|
||||
className="w-full px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-gray-800 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Pool Statistics</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
View detailed pool statistics in the Pools tab
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Modals */}
|
||||
<CreatePoolModal
|
||||
isOpen={showCreatePoolModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<InitializeHezPoolModal
|
||||
isOpen={showInitializeHezPoolModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<InitializeUsdtModal
|
||||
isOpen={showInitializeUsdtModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<XCMConfigurationWizard
|
||||
isOpen={showXcmBridgeModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface InitializeHezPoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const InitializeHezPoolModal: React.FC<InitializeHezPoolModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [hezAmount, setHezAmount] = useState('100000');
|
||||
|
||||
const [hezBalance, setHezBalance] = useState<string>('0');
|
||||
const [whezBalance, setWhezBalance] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHezAmount('100000');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
try {
|
||||
// HEZ balance (native)
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setHezBalance(freeBalance);
|
||||
|
||||
// wHEZ balance (asset 0)
|
||||
const whezData = await api.query.assets.account(0, account);
|
||||
setWhezBalance(whezData.isSome ? whezData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch balances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account]);
|
||||
|
||||
const handleWrap = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hezAmountRaw = BigInt(parseFloat(hezAmount) * 10 ** 12);
|
||||
|
||||
if (hezAmountRaw <= BigInt(0)) {
|
||||
setErrorMessage('Amount must be greater than zero');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hezAmountRaw > BigInt(hezBalance)) {
|
||||
setErrorMessage('Insufficient HEZ balance');
|
||||
return;
|
||||
}
|
||||
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('🔄 Wrapping HEZ to wHEZ...', {
|
||||
hezAmount,
|
||||
hezAmountRaw: hezAmountRaw.toString(),
|
||||
});
|
||||
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(hezAmountRaw.toString());
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await wrapTx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📦 Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ In block:', status.asInBlock.toHex());
|
||||
|
||||
if (dispatchError) {
|
||||
let errorMsg = '';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Module error:', errorMsg);
|
||||
} else {
|
||||
errorMsg = dispatchError.toString();
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Dispatch error:', errorMsg);
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg);
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Transaction Failed',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Wrap successful!');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📋 Events:', events.map(e => e.event.method).join(', '));
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: `Successfully wrapped ${hezAmount} HEZ to wHEZ`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Wrap failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Wrap failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hezBalanceDisplay = (parseFloat(hezBalance) / 10 ** 12).toFixed(4);
|
||||
const whezBalanceDisplay = (parseFloat(whezBalance) / 10 ** 12).toFixed(4);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Wrap HEZ to wHEZ
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-blue-600/20 text-blue-400 border-blue-600/30 w-fit mt-2">
|
||||
Admin Only - Token Wrapping
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<Alert className="bg-blue-500/10 border-blue-500/30">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
<AlertDescription className="text-blue-300 text-sm">
|
||||
Convert native HEZ tokens to wHEZ (wrapped HEZ) tokens for use in DEX pools.
|
||||
Ratio is always 1:1.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* HEZ Amount */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">HEZ Amount</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {hezBalanceDisplay} HEZ
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={hezAmount}
|
||||
onChange={(e) => setHezAmount(e.target.value)}
|
||||
placeholder="1000"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setHezAmount((parseFloat(hezBalance) / 10 ** 12).toFixed(4))}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 You will receive {hezAmount} wHEZ (1:1 ratio)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current wHEZ Balance */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">Current wHEZ Balance</div>
|
||||
<div className="text-2xl font-bold text-cyan-400 font-mono">
|
||||
{whezBalanceDisplay} wHEZ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<Alert className="bg-green-500/10 border-green-500/30">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<AlertDescription className="text-green-300 text-sm">
|
||||
Successfully wrapped {hezAmount} HEZ to wHEZ!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1 border-gray-700 hover:bg-gray-800"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleWrap}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Wrapping...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Wrap HEZ'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface InitializeUsdtModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
const USDT_ASSET_ID = 1000; // wUSDT asset ID (matches runtime WUSDT_ASSET_ID)
|
||||
const USDT_DECIMALS = 6; // USDT standard decimals
|
||||
|
||||
export const InitializeUsdtModal: React.FC<InitializeUsdtModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [usdtAmount, setUsdtAmount] = useState('10000');
|
||||
|
||||
const [wusdtBalance, setWusdtBalance] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setUsdtAmount('10000');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch wUSDT balance
|
||||
useEffect(() => {
|
||||
const fetchBalance = async () => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
try {
|
||||
// wUSDT balance (asset 2)
|
||||
const wusdtData = await api.query.assets.account(USDT_ASSET_ID, account);
|
||||
setWusdtBalance(wusdtData.isSome ? wusdtData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch wUSDT balance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalance();
|
||||
}, [api, isApiReady, account]);
|
||||
|
||||
const handleMint = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const usdtAmountRaw = BigInt(parseFloat(usdtAmount) * 10 ** USDT_DECIMALS);
|
||||
|
||||
if (usdtAmountRaw <= BigInt(0)) {
|
||||
setErrorMessage('Amount must be greater than zero');
|
||||
return;
|
||||
}
|
||||
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('💵 Minting wUSDT...', {
|
||||
usdtAmount,
|
||||
usdtAmountRaw: usdtAmountRaw.toString(),
|
||||
assetId: USDT_ASSET_ID,
|
||||
});
|
||||
|
||||
const mintTx = api.tx.assets.mint(USDT_ASSET_ID, account, usdtAmountRaw.toString());
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await mintTx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📦 Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ In block:', status.asInBlock.toHex());
|
||||
|
||||
if (dispatchError) {
|
||||
let errorMsg = '';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Module error:', errorMsg);
|
||||
} else {
|
||||
errorMsg = dispatchError.toString();
|
||||
if (process.env.NODE_ENV !== 'production') console.error('❌ Dispatch error:', errorMsg);
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg);
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Transaction Failed',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Mint successful!');
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📋 Events:', events.map(e => e.event.method).join(', '));
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: `Successfully minted ${usdtAmount} wUSDT`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Mint failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Mint failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const wusdtBalanceDisplay = (parseFloat(wusdtBalance) / 10 ** USDT_DECIMALS).toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Mint wUSDT Tokens
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30 w-fit mt-2">
|
||||
Admin Only - Token Minting
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<Alert className="bg-green-500/10 border-green-500/30">
|
||||
<Info className="h-4 w-4 text-green-400" />
|
||||
<AlertDescription className="text-green-300 text-sm">
|
||||
Mint wUSDT (Wrapped USDT) tokens for testing and liquidity pool creation.
|
||||
Use responsibly!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* USDT Amount */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">wUSDT Amount</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Current: {wusdtBalanceDisplay} wUSDT
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={usdtAmount}
|
||||
onChange={(e) => setUsdtAmount(e.target.value)}
|
||||
placeholder="10000"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setUsdtAmount('10000')}
|
||||
className="px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
10K
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsdtAmount('100000')}
|
||||
className="px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
100K
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 wUSDT has 6 decimals (same as real USDT)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current wUSDT Balance */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">Current wUSDT Balance</div>
|
||||
<div className="text-2xl font-bold text-green-400 font-mono">
|
||||
{wusdtBalanceDisplay} wUSDT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<Alert className="bg-green-500/10 border-green-500/30">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<AlertDescription className="text-green-300 text-sm">
|
||||
Successfully minted {usdtAmount} wUSDT!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1 border-gray-700 hover:bg-gray-800"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMint}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Minting...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Mint wUSDT'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Droplet, Search, Plus } from 'lucide-react';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { fetchPools, formatTokenBalance } from '@pezkuwi/utils/dex';
|
||||
import { isFounderWallet } from '@pezkuwi/utils/auth';
|
||||
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
|
||||
|
||||
interface PoolBrowserProps {
|
||||
onAddLiquidity?: (pool: PoolInfo) => void;
|
||||
onRemoveLiquidity?: (pool: PoolInfo) => void;
|
||||
onSwap?: (pool: PoolInfo) => void;
|
||||
onCreatePool?: () => void;
|
||||
}
|
||||
|
||||
export const PoolBrowser: React.FC<PoolBrowserProps> = ({
|
||||
onAddLiquidity,
|
||||
onRemoveLiquidity,
|
||||
onSwap,
|
||||
onCreatePool,
|
||||
}) => {
|
||||
const { api, isApiReady, sudoKey } = usePezkuwi();
|
||||
const { account } = useWallet();
|
||||
const [pools, setPools] = useState<PoolInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const isFounder = account ? isFounderWallet(account.address, sudoKey) : false;
|
||||
|
||||
useEffect(() => {
|
||||
const loadPools = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const poolsData = await fetchPools(api);
|
||||
setPools(poolsData);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to load pools:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPools();
|
||||
|
||||
// Refresh pools every 10 seconds
|
||||
const interval = setInterval(loadPools, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const filteredPools = pools.filter((pool) => {
|
||||
if (!searchTerm) return true;
|
||||
const search = searchTerm.toLowerCase();
|
||||
return (
|
||||
pool.asset1Symbol.toLowerCase().includes(search) ||
|
||||
pool.asset2Symbol.toLowerCase().includes(search) ||
|
||||
pool.id.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
if (loading && pools.length === 0) {
|
||||
return <LoadingState message="Loading liquidity pools..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search and create */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pools by token..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFounder && onCreatePool && (
|
||||
<button
|
||||
onClick={onCreatePool}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Pool
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pools grid */}
|
||||
{filteredPools.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center text-gray-400">
|
||||
{searchTerm
|
||||
? 'No pools found matching your search'
|
||||
: 'No liquidity pools available yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredPools.map((pool) => (
|
||||
<PoolCard
|
||||
key={pool.id}
|
||||
pool={pool}
|
||||
onAddLiquidity={onAddLiquidity}
|
||||
onRemoveLiquidity={onRemoveLiquidity}
|
||||
onSwap={onSwap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PoolCardProps {
|
||||
pool: PoolInfo;
|
||||
onAddLiquidity?: (pool: PoolInfo) => void;
|
||||
onRemoveLiquidity?: (pool: PoolInfo) => void;
|
||||
onSwap?: (pool: PoolInfo) => void;
|
||||
}
|
||||
|
||||
const PoolCard: React.FC<PoolCardProps> = ({
|
||||
pool,
|
||||
onAddLiquidity,
|
||||
onRemoveLiquidity,
|
||||
onSwap,
|
||||
}) => {
|
||||
const reserve1Display = formatTokenBalance(
|
||||
pool.reserve1,
|
||||
pool.asset1Decimals,
|
||||
2
|
||||
);
|
||||
const reserve2Display = formatTokenBalance(
|
||||
pool.reserve2,
|
||||
pool.asset2Decimals,
|
||||
2
|
||||
);
|
||||
|
||||
// Calculate exchange rate
|
||||
const rate =
|
||||
BigInt(pool.reserve1) > BigInt(0)
|
||||
? (Number(pool.reserve2) / Number(pool.reserve1)).toFixed(4)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/50 border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span className="text-green-400">{pool.asset1Symbol}</span>
|
||||
<span className="text-gray-500">/</span>
|
||||
<span className="text-yellow-400">{pool.asset2Symbol}</span>
|
||||
</CardTitle>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Reserves */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Reserve {pool.asset1Symbol}</span>
|
||||
<span className="text-white font-mono">
|
||||
{reserve1Display} {pool.asset1Symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Reserve {pool.asset2Symbol}</span>
|
||||
<span className="text-white font-mono">
|
||||
{reserve2Display} {pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange rate */}
|
||||
<div className="p-3 bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-cyan-400 font-mono">
|
||||
1 {pool.asset1Symbol} = {rate} {pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Fee</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{pool.feeRate || '0.3'}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Volume 24h</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{pool.volume24h || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">APR</div>
|
||||
<div className="text-sm font-semibold text-green-400">
|
||||
{pool.apr7d || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2">
|
||||
{onAddLiquidity && (
|
||||
<button
|
||||
onClick={() => onAddLiquidity(pool)}
|
||||
className="px-3 py-2 bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-600/30 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<Droplet className="w-3 h-3" />
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
{onRemoveLiquidity && (
|
||||
<button
|
||||
onClick={() => onRemoveLiquidity(pool)}
|
||||
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/30 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{onSwap && (
|
||||
<button
|
||||
onClick={() => onSwap(pool)}
|
||||
className="px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Swap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Minus, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { formatTokenBalance } from '@pezkuwi/utils/dex';
|
||||
|
||||
interface RemoveLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
pool: PoolInfo | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const RemoveLiquidityModal: React.FC<RemoveLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
pool,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [lpTokenBalance, setLpTokenBalance] = useState<string>('0');
|
||||
const [removePercentage, setRemovePercentage] = useState(25);
|
||||
const [slippage, setSlippage] = useState(1); // 1% default
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes or pool changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pool) {
|
||||
setRemovePercentage(25);
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen, pool]);
|
||||
|
||||
// Fetch LP token balance
|
||||
useEffect(() => {
|
||||
const fetchLPBalance = async () => {
|
||||
if (!api || !isApiReady || !account || !pool) return;
|
||||
|
||||
try {
|
||||
// Get pool account
|
||||
const poolAccount = await api.query.assetConversion.pools([
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
]);
|
||||
|
||||
if (poolAccount.isNone) {
|
||||
setLpTokenBalance('0');
|
||||
return;
|
||||
}
|
||||
|
||||
// LP token ID is derived from pool ID
|
||||
// For now, we'll query the pool's LP token supply
|
||||
// In a real implementation, you'd need to query the specific LP token for the user
|
||||
if (api.query.assetConversion.nextPoolAssetId) {
|
||||
await api.query.assetConversion.nextPoolAssetId();
|
||||
}
|
||||
|
||||
// This is a simplified version - you'd need to track LP tokens properly
|
||||
setLpTokenBalance('0'); // Placeholder
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch LP balance:', error);
|
||||
setLpTokenBalance('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchLPBalance();
|
||||
}, [api, isApiReady, account, pool]);
|
||||
|
||||
const calculateOutputAmounts = () => {
|
||||
if (!pool || BigInt(lpTokenBalance) === BigInt(0)) {
|
||||
return { amount1: '0', amount2: '0' };
|
||||
}
|
||||
|
||||
// Calculate amounts based on percentage
|
||||
const lpAmount = (BigInt(lpTokenBalance) * BigInt(removePercentage)) / BigInt(100);
|
||||
|
||||
// Simplified calculation - in reality, this depends on total LP supply
|
||||
const totalLiquidity = BigInt(pool.reserve1) + BigInt(pool.reserve2);
|
||||
const userShare = lpAmount;
|
||||
|
||||
// Proportional amounts
|
||||
const amount1 = (BigInt(pool.reserve1) * userShare) / totalLiquidity;
|
||||
const amount2 = (BigInt(pool.reserve2) * userShare) / totalLiquidity;
|
||||
|
||||
return {
|
||||
amount1: amount1.toString(),
|
||||
amount2: amount2.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const handleRemoveLiquidity = async () => {
|
||||
if (!api || !isApiReady || !signer || !account || !pool) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (BigInt(lpTokenBalance) === BigInt(0)) {
|
||||
setErrorMessage('No liquidity to remove');
|
||||
return;
|
||||
}
|
||||
|
||||
const lpAmount = (BigInt(lpTokenBalance) * BigInt(removePercentage)) / BigInt(100);
|
||||
const { amount1, amount2 } = calculateOutputAmounts();
|
||||
|
||||
// Calculate minimum amounts with slippage tolerance
|
||||
const minAmount1 = (BigInt(amount1) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
const minAmount2 = (BigInt(amount2) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
const tx = api.tx.assetConversion.removeLiquidity(
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
lpAmount.toString(),
|
||||
minAmount1.toString(),
|
||||
minAmount2.toString(),
|
||||
account
|
||||
);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Remove liquidity failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !pool) return null;
|
||||
|
||||
const { amount1, amount2 } = calculateOutputAmounts();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Remove Liquidity
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Remove liquidity to receive your tokens back. You'll burn LP tokens in proportion to your withdrawal.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* LP Token Balance */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">Your LP Tokens</div>
|
||||
<div className="text-2xl font-bold text-white font-mono">
|
||||
{formatTokenBalance(lpTokenBalance, 12, 6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Percentage Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">Remove Amount</label>
|
||||
<span className="text-lg font-bold text-white">{removePercentage}%</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={removePercentage}
|
||||
onChange={(e) => setRemovePercentage(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[25, 50, 75, 100].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setRemovePercentage(value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
removePercentage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Minus className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Preview */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400 mb-2">You will receive</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">{pool.asset1Symbol}</span>
|
||||
<span className="text-white font-mono text-lg">
|
||||
{formatTokenBalance(amount1, pool.asset1Decimals, 6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">{pool.asset2Symbol}</span>
|
||||
<span className="text-white font-mono text-lg">
|
||||
{formatTokenBalance(amount2, pool.asset2Decimals, 6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slippage Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Slippage Tolerance</label>
|
||||
<div className="flex gap-2">
|
||||
{[0.5, 1, 2].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSlippage(value)}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
slippage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Liquidity removed successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveLiquidity}
|
||||
className="flex-1 px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success' ||
|
||||
BigInt(lpTokenBalance) === BigInt(0)
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Removing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Remove Liquidity'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,640 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { ArrowDownUp, AlertCircle, Loader2, Info, Settings, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import {
|
||||
parseTokenInput,
|
||||
formatTokenBalance,
|
||||
getAmountOut,
|
||||
calculatePriceImpact,
|
||||
} from '@pezkuwi/utils/dex';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface SwapInterfaceProps {
|
||||
initialPool?: PoolInfo | null;
|
||||
pools: PoolInfo[];
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
// User-facing tokens (wHEZ is hidden from users, shown as HEZ)
|
||||
const USER_TOKENS = [
|
||||
{ symbol: 'HEZ', emoji: '🟡', assetId: 0, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ' }, // actually wHEZ (asset 0)
|
||||
{ symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ' },
|
||||
{ symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT' },
|
||||
] as const;
|
||||
|
||||
export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [fromToken, setFromToken] = useState('HEZ');
|
||||
const [toToken, setToToken] = useState('PEZ');
|
||||
const [fromAmount, setFromAmount] = useState('');
|
||||
const [toAmount, setToAmount] = useState('');
|
||||
const [slippage, setSlippage] = useState(0.5); // 0.5% default
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const [fromBalance, setFromBalance] = useState<string>('0');
|
||||
const [toBalance, setToBalance] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Get asset IDs (for pool lookup)
|
||||
const getAssetId = (symbol: string) => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === symbol);
|
||||
return token?.assetId ?? null;
|
||||
};
|
||||
|
||||
const fromAssetId = getAssetId(fromToken);
|
||||
const toAssetId = getAssetId(toToken);
|
||||
|
||||
// Find active pool for selected pair
|
||||
const activePool = pools.find(
|
||||
(p) =>
|
||||
(p.asset1 === fromAssetId && p.asset2 === toAssetId) ||
|
||||
(p.asset1 === toAssetId && p.asset2 === fromAssetId)
|
||||
);
|
||||
|
||||
// Get token info
|
||||
const fromTokenInfo = USER_TOKENS.find(t => t.symbol === fromToken);
|
||||
const toTokenInfo = USER_TOKENS.find(t => t.symbol === toToken);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
// For HEZ, fetch native balance (not wHEZ asset balance)
|
||||
if (fromToken === 'HEZ') {
|
||||
try {
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setFromBalance(freeBalance);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch HEZ balance:', error);
|
||||
setFromBalance('0');
|
||||
}
|
||||
} else if (fromAssetId !== null) {
|
||||
try {
|
||||
const balanceData = await api.query.assets.account(fromAssetId, account);
|
||||
setFromBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch from balance:', error);
|
||||
setFromBalance('0');
|
||||
}
|
||||
}
|
||||
|
||||
// For HEZ, fetch native balance
|
||||
if (toToken === 'HEZ') {
|
||||
try {
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setToBalance(freeBalance);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch HEZ balance:', error);
|
||||
setToBalance('0');
|
||||
}
|
||||
} else if (toAssetId !== null) {
|
||||
try {
|
||||
const balanceData = await api.query.assets.account(toAssetId, account);
|
||||
setToBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch to balance:', error);
|
||||
setToBalance('0');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, fromToken, toToken, fromAssetId, toAssetId]);
|
||||
|
||||
// Calculate output amount when input changes
|
||||
useEffect(() => {
|
||||
if (!fromAmount || !activePool || !fromTokenInfo || !toTokenInfo) {
|
||||
setToAmount('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
||||
|
||||
// Determine direction and calculate output
|
||||
const isForward = activePool.asset1 === fromAssetId;
|
||||
const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2;
|
||||
const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1;
|
||||
|
||||
const toAmountRaw = getAmountOut(fromAmountRaw, reserveIn, reserveOut, 30); // 3% fee
|
||||
const toAmountDisplay = formatTokenBalance(toAmountRaw, toTokenInfo.decimals, 6);
|
||||
|
||||
setToAmount(toAmountDisplay);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to calculate output:', error);
|
||||
setToAmount('');
|
||||
}
|
||||
}, [fromAmount, activePool, fromTokenInfo, toTokenInfo, fromAssetId, toAssetId]);
|
||||
|
||||
// Calculate price impact
|
||||
const priceImpact = React.useMemo(() => {
|
||||
if (!fromAmount || !activePool || !fromAssetId || !toAssetId || !fromTokenInfo) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
||||
const isForward = activePool.asset1 === fromAssetId;
|
||||
const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2;
|
||||
const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1;
|
||||
|
||||
return parseFloat(calculatePriceImpact(reserveIn, reserveOut, fromAmountRaw));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}, [fromAmount, activePool, fromAssetId, toAssetId, fromTokenInfo]);
|
||||
|
||||
// Check if user has insufficient balance
|
||||
const hasInsufficientBalance = React.useMemo(() => {
|
||||
const fromAmountNum = parseFloat(fromAmount || '0');
|
||||
const fromBalanceNum = parseFloat(formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 6));
|
||||
return fromAmountNum > 0 && fromAmountNum > fromBalanceNum;
|
||||
}, [fromAmount, fromBalance, fromTokenInfo]);
|
||||
|
||||
const handleSwapDirection = () => {
|
||||
const tempToken = fromToken;
|
||||
const tempBalance = fromBalance;
|
||||
|
||||
setFromToken(toToken);
|
||||
setToToken(tempToken);
|
||||
setFromAmount(toAmount);
|
||||
setFromBalance(toBalance);
|
||||
setToBalance(tempBalance);
|
||||
};
|
||||
|
||||
const handleMaxClick = () => {
|
||||
if (fromTokenInfo) {
|
||||
const maxAmount = formatTokenBalance(fromBalance, fromTokenInfo.decimals, 6);
|
||||
setFromAmount(maxAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwap = async () => {
|
||||
if (!api || !signer || !account || !fromTokenInfo || !toTokenInfo) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activePool) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'No liquidity pool available for this pair',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTxStatus('signing');
|
||||
setShowConfirm(false);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const amountIn = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
||||
const minAmountOut = parseTokenInput(
|
||||
(parseFloat(toAmount) * (1 - slippage / 100)).toString(),
|
||||
toTokenInfo.decimals
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('💰 Swap transaction:', {
|
||||
from: fromToken,
|
||||
to: toToken,
|
||||
amount: fromAmount,
|
||||
minOut: minAmountOut.toString(),
|
||||
});
|
||||
|
||||
let tx;
|
||||
|
||||
if (fromToken === 'HEZ' && toToken === 'PEZ') {
|
||||
// HEZ → PEZ: wrap(HEZ→wHEZ) then swap(wHEZ→PEZ)
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString());
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[0, 1], // wHEZ → PEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
tx = api.tx.utility.batchAll([wrapTx, swapTx]);
|
||||
|
||||
} else if (fromToken === 'PEZ' && toToken === 'HEZ') {
|
||||
// PEZ → HEZ: swap(PEZ→wHEZ) then unwrap(wHEZ→HEZ)
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[1, 0], // PEZ → wHEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString());
|
||||
tx = api.tx.utility.batchAll([swapTx, unwrapTx]);
|
||||
|
||||
} else if (fromToken === 'HEZ') {
|
||||
// HEZ → Any Asset: wrap(HEZ→wHEZ) then swap(wHEZ→Asset)
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString());
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[0, toAssetId!], // wHEZ → target asset
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
tx = api.tx.utility.batchAll([wrapTx, swapTx]);
|
||||
|
||||
} else if (toToken === 'HEZ') {
|
||||
// Any Asset → HEZ: swap(Asset→wHEZ) then unwrap(wHEZ→HEZ)
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[fromAssetId!, 0], // source asset → wHEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString());
|
||||
tx = api.tx.utility.batchAll([swapTx, unwrapTx]);
|
||||
|
||||
} else {
|
||||
// Direct swap between assets (PEZ ↔ USDT, etc.)
|
||||
tx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[fromAssetId!, toAssetId!],
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Transaction Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: `Swapped ${fromAmount} ${fromToken} for ~${toAmount} ${toToken}`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
setFromAmount('');
|
||||
setToAmount('');
|
||||
setTxStatus('idle');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Swap failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Swap transaction failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeRate = activePool && fromTokenInfo && toTokenInfo
|
||||
? (
|
||||
parseFloat(formatTokenBalance(activePool.reserve2, toTokenInfo.decimals, 6)) /
|
||||
parseFloat(formatTokenBalance(activePool.reserve1, fromTokenInfo.decimals, 6))
|
||||
).toFixed(6)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Transaction Loading Overlay */}
|
||||
{(txStatus === 'signing' || txStatus === 'submitting') && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-green-400" />
|
||||
<p className="text-white text-xl font-semibold">
|
||||
{txStatus === 'signing' ? 'Waiting for signature...' : 'Processing swap...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">Swap Tokens</CardTitle>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowSettings(true)}>
|
||||
<Settings className="h-5 w-5 text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
{!account && (
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-300">
|
||||
Please connect your wallet to swap tokens
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* From Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">From</span>
|
||||
<span className="text-gray-400">
|
||||
Balance: {formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 4)} {fromToken}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
value={fromAmount}
|
||||
onChange={(e) => setFromAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-600 focus-visible:ring-0"
|
||||
disabled={!account}
|
||||
/>
|
||||
<Select
|
||||
value={fromToken}
|
||||
onValueChange={(value) => {
|
||||
setFromToken(value);
|
||||
if (value === toToken) {
|
||||
const otherToken = USER_TOKENS.find(t => t.symbol !== value);
|
||||
if (otherToken) setToToken(otherToken.symbol);
|
||||
}
|
||||
}}
|
||||
disabled={!account}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] border-gray-600 bg-gray-900">
|
||||
<SelectValue>
|
||||
{(() => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === fromToken);
|
||||
return <span className="flex items-center gap-2">{token?.emoji} {token?.displaySymbol}</span>;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-gray-700">
|
||||
{USER_TOKENS.map((token) => (
|
||||
<SelectItem key={token.symbol} value={token.symbol}>
|
||||
<span className="flex items-center gap-2">{token.emoji} {token.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMaxClick}
|
||||
className="px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={!account}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Direction Button */}
|
||||
<div className="flex justify-center -my-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapDirection}
|
||||
className="rounded-full bg-gray-800 border-2 border-gray-700 hover:bg-gray-700"
|
||||
disabled={!account}
|
||||
>
|
||||
<ArrowDownUp className="h-5 w-5 text-gray-300" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* To Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">To</span>
|
||||
<span className="text-gray-400">
|
||||
Balance: {formatTokenBalance(toBalance, toTokenInfo?.decimals ?? 12, 4)} {toToken}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={toAmount}
|
||||
readOnly
|
||||
placeholder="0.0"
|
||||
className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-600 focus-visible:ring-0"
|
||||
/>
|
||||
<Select
|
||||
value={toToken}
|
||||
onValueChange={(value) => {
|
||||
setToToken(value);
|
||||
if (value === fromToken) {
|
||||
const otherToken = USER_TOKENS.find(t => t.symbol !== value);
|
||||
if (otherToken) setFromToken(otherToken.symbol);
|
||||
}
|
||||
}}
|
||||
disabled={!account}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] border-gray-600 bg-gray-900">
|
||||
<SelectValue>
|
||||
{(() => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === toToken);
|
||||
return <span className="flex items-center gap-2">{token?.emoji} {token?.displaySymbol}</span>;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-gray-700">
|
||||
{USER_TOKENS.map((token) => (
|
||||
<SelectItem key={token.symbol} value={token.symbol}>
|
||||
<span className="flex items-center gap-2">{token.emoji} {token.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Details */}
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Exchange Rate
|
||||
</span>
|
||||
<span className="text-white">
|
||||
{activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<AlertTriangle className={`w-3 h-3 ${
|
||||
priceImpact < 1 ? 'text-green-500' :
|
||||
priceImpact < 5 ? 'text-yellow-500' :
|
||||
'text-red-500'
|
||||
}`} />
|
||||
Price Impact
|
||||
</span>
|
||||
<span className={`font-semibold ${
|
||||
priceImpact < 1 ? 'text-green-400' :
|
||||
priceImpact < 5 ? 'text-yellow-400' :
|
||||
'text-red-400'
|
||||
}`}>
|
||||
{priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-2 border-t border-gray-700">
|
||||
<span className="text-gray-400">Slippage Tolerance</span>
|
||||
<span className="text-blue-400">{slippage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{hasInsufficientBalance && (
|
||||
<Alert className="bg-red-900/20 border-red-500/30">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
Insufficient {fromToken} balance
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{priceImpact >= 5 && !hasInsufficientBalance && (
|
||||
<Alert className="bg-red-900/20 border-red-500/30">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
High price impact! Consider a smaller amount.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Swap Button */}
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={
|
||||
!account ||
|
||||
!fromAmount ||
|
||||
parseFloat(fromAmount) <= 0 ||
|
||||
!activePool ||
|
||||
hasInsufficientBalance ||
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting'
|
||||
}
|
||||
>
|
||||
{!account
|
||||
? 'Connect Wallet'
|
||||
: hasInsufficientBalance
|
||||
? `Insufficient ${fromToken} Balance`
|
||||
: !activePool
|
||||
? 'No Pool Available'
|
||||
: 'Swap Tokens'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Swap Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300">Slippage Tolerance</label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[0.1, 0.5, 1.0, 2.0].map(val => (
|
||||
<Button
|
||||
key={val}
|
||||
variant={slippage === val ? 'default' : 'outline'}
|
||||
onClick={() => setSlippage(val)}
|
||||
className="flex-1"
|
||||
>
|
||||
{val}%
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Confirm Swap</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">You Pay</span>
|
||||
<span className="font-bold text-white">{fromAmount} {fromToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">You Receive</span>
|
||||
<span className="font-bold text-white">{toAmount} {toToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-gray-700">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-gray-400">1 {fromToken} = {exchangeRate} {toToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Slippage</span>
|
||||
<span className="text-gray-400">{slippage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleConfirmSwap}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{txStatus === 'signing' ? 'Signing...' : txStatus === 'submitting' ? 'Swapping...' : 'Confirm Swap'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
// Force reload for mock XCM update
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, AlertCircle, Loader2, CheckCircle, Info, ExternalLink, Zap } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
checkBridgeStatus,
|
||||
fetchAssetHubUsdtInfo,
|
||||
configureXcmBridge,
|
||||
|
||||
createWUsdtHezPool,
|
||||
ASSET_HUB_USDT_ID,
|
||||
WUSDT_ASSET_ID,
|
||||
ASSET_HUB_ENDPOINT,
|
||||
type BridgeStatus,
|
||||
type AssetHubUsdtInfo,
|
||||
} from '@pezkuwi/lib/xcm-bridge';
|
||||
|
||||
interface XCMBridgeSetupModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'idle' | 'checking' | 'fetching' | 'configuring' | 'pool-creation' | 'success' | 'error';
|
||||
|
||||
export const XCMBridgeSetupModal: React.FC<XCMBridgeSetupModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
const [step, setStep] = useState<SetupStep>('idle');
|
||||
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
|
||||
const [assetHubInfo, setAssetHubInfo] = useState<AssetHubUsdtInfo | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [showPoolCreation, setShowPoolCreation] = useState(false);
|
||||
const [wusdtAmount, setWusdtAmount] = useState('1000');
|
||||
const [hezAmount, setHezAmount] = useState('10');
|
||||
|
||||
/**
|
||||
* Perform initial status check
|
||||
*/
|
||||
const performInitialCheck = useCallback(async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
setStep('checking');
|
||||
setStatusMessage('Checking bridge status...');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
// Check current bridge status
|
||||
const status = await checkBridgeStatus(api);
|
||||
setBridgeStatus(status);
|
||||
|
||||
// Fetch Asset Hub USDT info
|
||||
setStatusMessage('Fetching Asset Hub USDT info...');
|
||||
const info = await fetchAssetHubUsdtInfo();
|
||||
setAssetHubInfo(info);
|
||||
|
||||
setStatusMessage('Status check complete');
|
||||
setStep('idle');
|
||||
} catch (error) {
|
||||
console.error('Initial check failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Status check failed');
|
||||
setStep('error');
|
||||
}
|
||||
}, [api, isApiReady]);
|
||||
|
||||
// Reset when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStep('idle');
|
||||
setStatusMessage('');
|
||||
setErrorMessage('');
|
||||
setShowPoolCreation(false);
|
||||
} else {
|
||||
// Auto-check status when opened
|
||||
if (api && isApiReady && account) {
|
||||
performInitialCheck();
|
||||
}
|
||||
}
|
||||
}, [isOpen, api, isApiReady, account, performInitialCheck]);
|
||||
|
||||
/**
|
||||
* Configure XCM bridge
|
||||
*/
|
||||
const handleConfigureBridge = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('configuring');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await configureXcmBridge(
|
||||
api,
|
||||
signer,
|
||||
account,
|
||||
(status) => setStatusMessage(status)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: 'XCM bridge configured successfully',
|
||||
});
|
||||
|
||||
// Refresh status
|
||||
await performInitialCheck();
|
||||
|
||||
setStep('success');
|
||||
setStatusMessage('Bridge configuration complete!');
|
||||
} catch (error) {
|
||||
console.error('Bridge configuration failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Configuration failed');
|
||||
setStep('error');
|
||||
toast({
|
||||
title: 'Configuration Failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create wUSDT/HEZ pool
|
||||
*/
|
||||
const handleCreatePool = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('pool-creation');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
// Convert amounts to raw values (6 decimals for wUSDT, 12 for HEZ)
|
||||
const wusdtRaw = BigInt(parseFloat(wusdtAmount) * 10 ** 6).toString();
|
||||
const hezRaw = BigInt(parseFloat(hezAmount) * 10 ** 12).toString();
|
||||
|
||||
await createWUsdtHezPool(
|
||||
api,
|
||||
signer,
|
||||
account,
|
||||
wusdtRaw,
|
||||
hezRaw,
|
||||
(status) => setStatusMessage(status)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: 'wUSDT/HEZ pool created successfully',
|
||||
});
|
||||
|
||||
setStep('success');
|
||||
setStatusMessage('Pool creation complete!');
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Pool creation failed:', error);
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Pool creation failed');
|
||||
setStep('error');
|
||||
toast({
|
||||
title: 'Pool Creation Failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isLoading = step === 'checking' || step === 'fetching' || step === 'configuring' || step === 'pool-creation';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
XCM Bridge Setup
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-purple-600/20 text-purple-400 border-purple-600/30 w-fit mt-2">
|
||||
Admin Only - XCM Configuration
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<Alert className="bg-purple-500/10 border-purple-500/30">
|
||||
<Zap className="h-4 w-4 text-purple-400" />
|
||||
<AlertDescription className="text-purple-300 text-sm">
|
||||
Configure Asset Hub USDT → wUSDT bridge with one click. This enables
|
||||
cross-chain transfers from Westend Asset Hub to PezkuwiChain.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Current Status */}
|
||||
{bridgeStatus && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-3">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-2">Current Status</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Asset Hub Connection:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridgeStatus.assetHubConnected ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-yellow-400" />
|
||||
)}
|
||||
<span className={bridgeStatus.assetHubConnected ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{bridgeStatus.assetHubConnected ? 'Connected' : 'Checking...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">wUSDT Asset Exists:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridgeStatus.wusdtExists ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span className={bridgeStatus.wusdtExists ? 'text-green-400' : 'text-red-400'}>
|
||||
{bridgeStatus.wusdtExists ? 'Yes (ID: 1000)' : 'Not Found'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">XCM Bridge Configured:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridgeStatus.isConfigured ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-yellow-400" />
|
||||
)}
|
||||
<span className={bridgeStatus.isConfigured ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{bridgeStatus.isConfigured ? 'Configured' : 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset Hub USDT Info */}
|
||||
{assetHubInfo && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-2">Asset Hub USDT Info</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-gray-400">Asset ID:</span>
|
||||
<span className="text-white font-mono">{assetHubInfo.id}</span>
|
||||
|
||||
<span className="text-gray-400">Symbol:</span>
|
||||
<span className="text-white">{assetHubInfo.symbol}</span>
|
||||
|
||||
<span className="text-gray-400">Decimals:</span>
|
||||
<span className="text-white">{assetHubInfo.decimals}</span>
|
||||
|
||||
<span className="text-gray-400">Total Supply:</span>
|
||||
<span className="text-white">{(parseFloat(assetHubInfo.supply) / 10 ** 6).toLocaleString()} USDT</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://westend-assethub.subscan.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors mt-2"
|
||||
>
|
||||
View on Subscan <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Details */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-2">Configuration Details</div>
|
||||
<div className="text-xs space-y-1 text-gray-400 font-mono">
|
||||
<div>Asset Hub Endpoint: {ASSET_HUB_ENDPOINT}</div>
|
||||
<div>Asset Hub USDT ID: {ASSET_HUB_USDT_ID}</div>
|
||||
<div>PezkuwiChain wUSDT ID: {WUSDT_ASSET_ID}</div>
|
||||
<div>Parachain ID: 1000 (Asset Hub)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{statusMessage && (
|
||||
<Alert className="bg-blue-500/10 border-blue-500/30">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
<AlertDescription className="text-blue-300 text-sm">
|
||||
{statusMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{step === 'success' && (
|
||||
<Alert className="bg-green-500/10 border-green-500/30">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<AlertDescription className="text-green-300 text-sm">
|
||||
{statusMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Pool Creation Section (Optional) */}
|
||||
{showPoolCreation && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-4">
|
||||
<div className="text-sm font-semibold text-gray-300">Create wUSDT/HEZ Pool (Optional)</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">wUSDT Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={wusdtAmount}
|
||||
onChange={(e) => setWusdtAmount(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm"
|
||||
placeholder="1000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">HEZ Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hezAmount}
|
||||
onChange={(e) => setHezAmount(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1 border-gray-700 hover:bg-gray-800"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
{!bridgeStatus?.isConfigured && (
|
||||
<Button
|
||||
onClick={handleConfigureBridge}
|
||||
className="flex-1 bg-purple-600 hover:bg-purple-700"
|
||||
disabled={isLoading || !bridgeStatus?.assetHubConnected}
|
||||
>
|
||||
{step === 'configuring' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Configuring...
|
||||
</>
|
||||
) : (
|
||||
'Configure Bridge'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{bridgeStatus?.isConfigured && !showPoolCreation && (
|
||||
<Button
|
||||
onClick={() => setShowPoolCreation(true)}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Create Pool (Optional)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showPoolCreation && (
|
||||
<Button
|
||||
onClick={handleCreatePool}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{step === 'pool-creation' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Pool'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
⚠️ XCM bridge configuration requires sudo access
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ThumbsUp, ThumbsDown, MessageSquare, Shield, MoreVertical, Flag, Edit, Trash2 } from 'lucide-react';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useWebSocket } from '@/contexts/WebSocketContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
author: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
isExpert: boolean;
|
||||
badges: string[];
|
||||
replies: Comment[];
|
||||
sentiment: 'positive' | 'neutral' | 'negative';
|
||||
userVote?: 'up' | 'down' | null;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export function DiscussionThread({ proposalId }: { proposalId: string }) {
|
||||
const { toast } = useToast();
|
||||
const { subscribe, unsubscribe, sendMessage, isConnected } = useWebSocket();
|
||||
const [comments, setComments] = useState<Comment[]>([
|
||||
{
|
||||
id: '1',
|
||||
author: 'Dr. Rojin Ahmed',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
content: '## Strong Support for This Proposal\n\nThis proposal addresses a critical need in our governance system. The implementation timeline is realistic and the budget allocation seems appropriate.\n\n**Key Benefits:**\n- Improved transparency\n- Better community engagement\n- Clear accountability metrics\n\nI particularly appreciate the phased approach outlined in section 3.',
|
||||
timestamp: '2 hours ago',
|
||||
upvotes: 24,
|
||||
downvotes: 2,
|
||||
isExpert: true,
|
||||
badges: ['Governance Expert', 'Top Contributor'],
|
||||
sentiment: 'positive',
|
||||
userVote: null,
|
||||
replies: [
|
||||
{
|
||||
id: '1-1',
|
||||
author: 'Kawa Mustafa',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
content: 'Agreed! The phased approach reduces risk significantly.',
|
||||
timestamp: '1 hour ago',
|
||||
upvotes: 8,
|
||||
downvotes: 0,
|
||||
isExpert: false,
|
||||
badges: ['Active Member'],
|
||||
sentiment: 'positive',
|
||||
userVote: null,
|
||||
replies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: 'Dilan Karim',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
content: '### Concerns About Implementation\n\nWhile I support the overall direction, I have concerns about:\n\n1. The technical complexity might be underestimated\n2. We need more details on the security audit process\n3. Reference to [Proposal #142](/proposals/142) shows similar challenges\n\n> "The devil is in the details" - and we need more of them',
|
||||
timestamp: '3 hours ago',
|
||||
upvotes: 18,
|
||||
downvotes: 5,
|
||||
isExpert: true,
|
||||
badges: ['Security Expert'],
|
||||
sentiment: 'negative',
|
||||
userVote: null,
|
||||
replies: []
|
||||
}
|
||||
]);
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [replyTo, setReplyTo] = useState<string | null>(null);
|
||||
const [showMarkdownHelp, setShowMarkdownHelp] = useState(false);
|
||||
|
||||
// WebSocket subscriptions for real-time updates
|
||||
useEffect(() => {
|
||||
const handleNewComment = (data: Record<string, unknown>) => {
|
||||
const newComment: Comment = {
|
||||
...data,
|
||||
isLive: true,
|
||||
};
|
||||
setComments(prev => [newComment, ...prev]);
|
||||
|
||||
// Show notification for mentions
|
||||
if (data.content.includes('@currentUser')) {
|
||||
toast({
|
||||
title: "You were mentioned",
|
||||
description: `${data.author} mentioned you in a comment`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteUpdate = (data: { commentId: string; upvotes: number; downvotes: number }) => {
|
||||
setComments(prev => updateVoteCounts(prev, data.commentId, data.upvotes, data.downvotes));
|
||||
};
|
||||
|
||||
const handleSentimentUpdate = (data: { proposalId: string; sentiment: Record<string, unknown> }) => {
|
||||
if (data.proposalId === proposalId) {
|
||||
// Update sentiment visualization in parent component
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Sentiment updated:', data.sentiment);
|
||||
}
|
||||
};
|
||||
|
||||
subscribe('comment', handleNewComment);
|
||||
subscribe('vote', handleVoteUpdate);
|
||||
subscribe('sentiment', handleSentimentUpdate);
|
||||
|
||||
return () => {
|
||||
unsubscribe('comment', handleNewComment);
|
||||
unsubscribe('vote', handleVoteUpdate);
|
||||
unsubscribe('sentiment', handleSentimentUpdate);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subscribe, unsubscribe, proposalId, toast]);
|
||||
|
||||
|
||||
const updateVoteCounts = (comments: Comment[], targetId: string, upvotes: number, downvotes: number): Comment[] => {
|
||||
return comments.map(comment => {
|
||||
if (comment.id === targetId) {
|
||||
return { ...comment, upvotes, downvotes };
|
||||
}
|
||||
if (comment.replies.length > 0) {
|
||||
return {
|
||||
...comment,
|
||||
replies: updateVoteCounts(comment.replies, targetId, upvotes, downvotes)
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
};
|
||||
|
||||
const handleVote = useCallback((commentId: string, voteType: 'up' | 'down') => {
|
||||
const updatedComments = updateCommentVote(comments, commentId, voteType);
|
||||
setComments(updatedComments);
|
||||
|
||||
// Send vote update via WebSocket
|
||||
const comment = findComment(updatedComments, commentId);
|
||||
if (comment && isConnected) {
|
||||
sendMessage({
|
||||
type: 'vote',
|
||||
data: {
|
||||
commentId,
|
||||
upvotes: comment.upvotes,
|
||||
downvotes: comment.downvotes,
|
||||
proposalId,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [comments, isConnected, sendMessage, proposalId]);
|
||||
|
||||
const findComment = (comments: Comment[], targetId: string): Comment | null => {
|
||||
for (const comment of comments) {
|
||||
if (comment.id === targetId) return comment;
|
||||
const found = findComment(comment.replies, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateCommentVote = (comments: Comment[], targetId: string, voteType: 'up' | 'down'): Comment[] => {
|
||||
return comments.map(comment => {
|
||||
if (comment.id === targetId) {
|
||||
const wasUpvoted = comment.userVote === 'up';
|
||||
const wasDownvoted = comment.userVote === 'down';
|
||||
|
||||
if (voteType === 'up') {
|
||||
return {
|
||||
...comment,
|
||||
upvotes: wasUpvoted ? comment.upvotes - 1 : comment.upvotes + 1,
|
||||
downvotes: wasDownvoted ? comment.downvotes - 1 : comment.downvotes,
|
||||
userVote: wasUpvoted ? null : 'up'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...comment,
|
||||
upvotes: wasUpvoted ? comment.upvotes - 1 : comment.upvotes,
|
||||
downvotes: wasDownvoted ? comment.downvotes - 1 : comment.downvotes + 1,
|
||||
userVote: wasDownvoted ? null : 'down'
|
||||
};
|
||||
}
|
||||
}
|
||||
if (comment.replies.length > 0) {
|
||||
return {
|
||||
...comment,
|
||||
replies: updateCommentVote(comment.replies, targetId, voteType)
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
};
|
||||
|
||||
const renderComment = (comment: Comment, depth: number = 0) => (
|
||||
<div key={comment.id} className={`${depth > 0 ? 'ml-12 mt-4' : 'mb-6'} ${comment.isLive ? 'animate-pulse-once' : ''}`}>
|
||||
<Card className="border-l-4 transition-all duration-300" style={{
|
||||
borderLeftColor: comment.sentiment === 'positive' ? '#10b981' :
|
||||
comment.sentiment === 'negative' ? '#ef4444' : '#6b7280'
|
||||
}}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="relative">
|
||||
<AvatarImage src={comment.avatar} />
|
||||
<AvatarFallback>{comment.author[0]}</AvatarFallback>
|
||||
{comment.isLive && (
|
||||
<div className="absolute -top-1 -right-1 h-3 w-3 bg-green-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{comment.author}</span>
|
||||
{comment.isExpert && (
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
{comment.badges.map(badge => (
|
||||
<Badge key={badge} variant="secondary" className="text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
<span className="text-sm text-gray-500">
|
||||
{comment.isLive ? 'Just now' : comment.timestamp}
|
||||
</span>
|
||||
{isConnected && (
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full" title="Real-time updates active" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: parseMarkdown(comment.content) }} />
|
||||
<div className="flex items-center space-x-4 mt-4">
|
||||
<Button
|
||||
variant={comment.userVote === 'up' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleVote(comment.id, 'up')}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
<span className="transition-all duration-300">{comment.upvotes}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={comment.userVote === 'down' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleVote(comment.id, 'down')}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
<span className="transition-all duration-300">{comment.downvotes}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setReplyTo(comment.id)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-1" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{replyTo === comment.id && (
|
||||
<div className="mt-4">
|
||||
<Textarea
|
||||
placeholder="Write your reply... @mention users to notify them"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setReplyTo(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (newComment.trim() && isConnected) {
|
||||
sendMessage({
|
||||
type: 'reply',
|
||||
data: {
|
||||
parentId: comment.id,
|
||||
content: newComment,
|
||||
proposalId,
|
||||
author: 'Current User',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
setReplyTo(null);
|
||||
setNewComment('');
|
||||
}}
|
||||
disabled={!newComment.trim()}
|
||||
>
|
||||
Post Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{comment.replies.map(reply => renderComment(reply, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const parseMarkdown = (text: string): string => {
|
||||
return text
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '<a href="$2" class="text-blue-600 hover:underline">$1</a>')
|
||||
.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-gray-300 pl-4 italic">$1</blockquote>')
|
||||
.replace(/\n/gim, '<br>');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-semibold">Discussion Forum</h3>
|
||||
<p className="text-sm text-gray-600">Share your thoughts and feedback on this proposal</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="Write your comment... (Markdown supported)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownHelp(!showMarkdownHelp)}
|
||||
>
|
||||
Markdown Help
|
||||
</Button>
|
||||
<Button>Post Comment</Button>
|
||||
</div>
|
||||
{showMarkdownHelp && (
|
||||
<Card className="mt-4 p-4 bg-gray-50 text-gray-900">
|
||||
<p className="text-sm font-semibold mb-2 text-gray-900">Markdown Formatting:</p>
|
||||
<ul className="text-sm space-y-1 text-gray-900">
|
||||
<li>**bold** → <strong>bold</strong></li>
|
||||
<li>*italic* → <em>italic</em></li>
|
||||
<li>[link](url) → <a href="#" className="text-blue-600">link</a></li>
|
||||
<li>> quote → <blockquote className="border-l-4 border-gray-300 pl-2">quote</blockquote></li>
|
||||
<li># Heading → <span className="font-bold text-lg">Heading</span></li>
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
{comments.map(comment => renderComment(comment))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
// Tabs not currently used from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
|
||||
import {
|
||||
MessageSquare,
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
Clock,
|
||||
Flame,
|
||||
Pin,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Plus,
|
||||
Megaphone,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
import { useForum } from '@/hooks/useForum';
|
||||
import { DiscussionThread } from './DiscussionThread';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function ForumOverview() {
|
||||
const { user } = useAuth();
|
||||
const { announcements, categories, discussions, loading, reactToDiscussion } = useForum();
|
||||
const [selectedDiscussion, setSelectedDiscussion] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
|
||||
const getAnnouncementStyle = (type: string) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
icon: AlertTriangle,
|
||||
bgClass: 'bg-red-500/10 border-red-500/20'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: AlertTriangle,
|
||||
bgClass: 'bg-yellow-500/10 border-yellow-500/20'
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: CheckCircle,
|
||||
bgClass: 'bg-green-500/10 border-green-500/20'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: Info,
|
||||
bgClass: 'bg-blue-500/10 border-blue-500/20'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDiscussions = discussions
|
||||
.filter(d => {
|
||||
const matchesSearch = d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
d.content.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = filterCategory === 'all' || d.category?.name.toLowerCase() === filterCategory.toLowerCase();
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'popular':
|
||||
return (b.upvotes || 0) - (a.upvotes || 0);
|
||||
case 'replies':
|
||||
return b.replies_count - a.replies_count;
|
||||
case 'views':
|
||||
return b.views_count - a.views_count;
|
||||
default:
|
||||
return new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedDiscussion) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedDiscussion(null)}
|
||||
>
|
||||
← Back to Forum
|
||||
</Button>
|
||||
<DiscussionThread proposalId={selectedDiscussion} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading forum..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Admin Announcements Banner */}
|
||||
{announcements.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{announcements.map((announcement) => {
|
||||
const style = getAnnouncementStyle(announcement.type);
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<Alert key={announcement.id} className={style.bgClass}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<AlertTitle className="text-lg font-semibold mb-2">
|
||||
{announcement.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{announcement.content}
|
||||
</AlertDescription>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Posted {formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forum Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Discussions</p>
|
||||
<p className="text-2xl font-bold">{discussions.length}</p>
|
||||
</div>
|
||||
<MessageSquare className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Categories</p>
|
||||
<p className="text-2xl font-bold">{categories.length}</p>
|
||||
</div>
|
||||
<Filter className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Users</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{new Set(discussions.map(d => d.author_id)).size}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Replies</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{discussions.reduce((sum, d) => sum + d.replies_count, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search, Filters & Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search discussions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.name.toLowerCase()}>
|
||||
{cat.icon} {cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">Most Recent</SelectItem>
|
||||
<SelectItem value="popular">Most Popular</SelectItem>
|
||||
<SelectItem value="replies">Most Replies</SelectItem>
|
||||
<SelectItem value="views">Most Viewed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{user && (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Discussion
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Categories Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{categories.map((category) => (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-all hover:scale-105"
|
||||
onClick={() => setFilterCategory(category.name.toLowerCase())}
|
||||
>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-4xl mb-2">{category.icon}</div>
|
||||
<h3 className="font-semibold">{category.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{discussions.filter(d => d.category?.id === category.id).length} discussions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Discussions List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDiscussions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No discussions found</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredDiscussions.map((discussion) => (
|
||||
<Card
|
||||
key={discussion.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setSelectedDiscussion(discussion.id)}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{discussion.is_pinned && (
|
||||
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-700 border-yellow-500/20">
|
||||
<Pin className="h-3 w-3 mr-1" />
|
||||
Pinned
|
||||
</Badge>
|
||||
)}
|
||||
{discussion.is_locked && (
|
||||
<Badge variant="secondary" className="bg-red-500/10 text-red-700 border-red-500/20">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
{discussion.category && (
|
||||
<Badge variant="outline">
|
||||
{discussion.category.icon} {discussion.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
{(discussion.upvotes || 0) > 10 && (
|
||||
<Badge variant="destructive">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Trending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold mb-2 hover:text-primary transition-colors">
|
||||
{discussion.title}
|
||||
</h3>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
||||
<span>by {discussion.author_name}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{discussion.replies_count} replies
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
{discussion.views_count} views
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatDistanceToNow(new Date(discussion.last_activity_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{discussion.tags && discussion.tags.length > 0 && (
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
{discussion.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voting */}
|
||||
<div className="flex flex-col items-center gap-2 min-w-[60px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reactToDiscussion(discussion.id, 'upvote');
|
||||
}}
|
||||
className="hover:text-green-500"
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-lg font-bold">
|
||||
{(discussion.upvotes || 0) - (discussion.downvotes || 0)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reactToDiscussion(discussion.id, 'downvote');
|
||||
}}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertTriangle, Shield, Ban, CheckCircle, Clock, Flag, User } from 'lucide-react';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
type: 'spam' | 'harassment' | 'misinformation' | 'other';
|
||||
reportedContent: string;
|
||||
reportedBy: string;
|
||||
reportedUser: string;
|
||||
timestamp: string;
|
||||
status: 'pending' | 'reviewing' | 'resolved';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export function ModerationPanel() {
|
||||
const [autoModeration, setAutoModeration] = useState(true);
|
||||
const [sentimentThreshold, setSentimentThreshold] = useState(30);
|
||||
|
||||
const reports: Report[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'misinformation',
|
||||
reportedContent: 'False claims about proposal implementation...',
|
||||
reportedBy: 'User123',
|
||||
reportedUser: 'BadActor456',
|
||||
timestamp: '10 minutes ago',
|
||||
status: 'pending',
|
||||
severity: 'high'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'spam',
|
||||
reportedContent: 'Repeated promotional content...',
|
||||
reportedBy: 'User789',
|
||||
reportedUser: 'Spammer101',
|
||||
timestamp: '1 hour ago',
|
||||
status: 'reviewing',
|
||||
severity: 'medium'
|
||||
}
|
||||
];
|
||||
|
||||
const moderationStats = {
|
||||
totalReports: 24,
|
||||
resolved: 18,
|
||||
pending: 6,
|
||||
bannedUsers: 3,
|
||||
flaggedContent: 12
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'high': return 'text-red-600 bg-red-100';
|
||||
case 'medium': return 'text-yellow-600 bg-yellow-100';
|
||||
case 'low': return 'text-green-600 bg-green-100';
|
||||
default: return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'resolved': return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||
case 'reviewing': return <Clock className="h-4 w-4 text-yellow-600" />;
|
||||
case 'pending': return <AlertTriangle className="h-4 w-4 text-red-600" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Moderation Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Reports</p>
|
||||
<p className="text-2xl font-bold">{moderationStats.totalReports}</p>
|
||||
</div>
|
||||
<Flag className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Resolved</p>
|
||||
<p className="text-2xl font-bold text-green-600">{moderationStats.resolved}</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{moderationStats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-yellow-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Banned Users</p>
|
||||
<p className="text-2xl font-bold text-red-600">{moderationStats.bannedUsers}</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Flagged Content</p>
|
||||
<p className="text-2xl font-bold">{moderationStats.flaggedContent}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="reports" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="reports">Reports Queue</TabsTrigger>
|
||||
<TabsTrigger value="settings">Auto-Moderation</TabsTrigger>
|
||||
<TabsTrigger value="users">User Management</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="reports" className="space-y-4">
|
||||
{reports.map((report) => (
|
||||
<Card key={report.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getStatusIcon(report.status)}
|
||||
<Badge className={getSeverityColor(report.severity)}>
|
||||
{report.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline">{report.type}</Badge>
|
||||
<span className="text-sm text-gray-500">{report.timestamp}</span>
|
||||
</div>
|
||||
<p className="font-medium mb-2">Reported User: {report.reportedUser}</p>
|
||||
<p className="text-gray-600 mb-3">{report.reportedContent}</p>
|
||||
<p className="text-sm text-gray-500">Reported by: {report.reportedBy}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Review
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm">
|
||||
Take Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auto-Moderation Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-mod">Enable Auto-Moderation</Label>
|
||||
<p className="text-sm text-gray-600">Automatically flag suspicious content</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-mod"
|
||||
checked={autoModeration}
|
||||
onCheckedChange={setAutoModeration}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sentiment Threshold</Label>
|
||||
<p className="text-sm text-gray-600">
|
||||
Flag comments with sentiment below {sentimentThreshold}%
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={sentimentThreshold}
|
||||
onChange={(e) => setSentimentThreshold(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Auto-moderation uses AI to detect potentially harmful content and automatically flags it for review.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Moderation Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-8 w-8 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium">BadActor456</p>
|
||||
<p className="text-sm text-gray-600">3 reports, 2 warnings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">Warn</Button>
|
||||
<Button variant="outline" size="sm">Suspend</Button>
|
||||
<Button variant="destructive" size="sm">Ban</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Vote, Trophy, AlertCircle, CheckCircle, Users, Clock, Activity, Loader2 } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import {
|
||||
getActiveElections,
|
||||
getElectionCandidates,
|
||||
getElectionResults,
|
||||
hasVoted,
|
||||
blocksToTime,
|
||||
getCurrentBlock,
|
||||
getElectionTypeLabel,
|
||||
getElectionStatusLabel,
|
||||
type ElectionInfo,
|
||||
type CandidateInfo,
|
||||
type ElectionResult
|
||||
} from '@pezkuwi/lib/welati';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ElectionWithCandidates extends ElectionInfo {
|
||||
candidates: CandidateInfo[];
|
||||
userHasVoted: boolean;
|
||||
}
|
||||
|
||||
const ElectionsInterface: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, signer } = useWallet();
|
||||
const [elections, setElections] = useState<ElectionWithCandidates[]>([]);
|
||||
const [completedResults, setCompletedResults] = useState<ElectionResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentBlock, setCurrentBlock] = useState(0);
|
||||
const [votingElectionId, setVotingElectionId] = useState<number | null>(null);
|
||||
const [selectedCandidates, setSelectedCandidates] = useState<Map<number, string[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchElectionData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get current block
|
||||
const block = await getCurrentBlock(api);
|
||||
setCurrentBlock(block);
|
||||
|
||||
// Get active elections
|
||||
const activeElections = await getActiveElections(api);
|
||||
|
||||
// Fetch candidates for each election
|
||||
const electionsWithCandidates: ElectionWithCandidates[] = await Promise.all(
|
||||
activeElections.map(async (election) => {
|
||||
const candidates = await getElectionCandidates(api, election.electionId);
|
||||
const userHasVoted = account
|
||||
? await hasVoted(api, election.electionId, account)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...election,
|
||||
candidates,
|
||||
userHasVoted
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setElections(electionsWithCandidates);
|
||||
|
||||
// Get completed election results (last 5)
|
||||
const results: ElectionResult[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = await getElectionResults(api, i);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
setCompletedResults(results);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching election data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch election data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchElectionData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchElectionData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, account]);
|
||||
|
||||
const handleVote = async (electionId: number, candidateAccount: string, electionType: string) => {
|
||||
if (!api || !account || !signer) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setVotingElectionId(electionId);
|
||||
|
||||
// Handle multi-select for parliamentary elections
|
||||
if (electionType === 'Parliamentary') {
|
||||
const current = selectedCandidates.get(electionId) || [];
|
||||
const updated = current.includes(candidateAccount)
|
||||
? current.filter(c => c !== candidateAccount)
|
||||
: [...current, candidateAccount];
|
||||
setSelectedCandidates(new Map(selectedCandidates.set(electionId, updated)));
|
||||
setVotingElectionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single vote for other elections
|
||||
const tx = api.tx.welati.vote(electionId, candidateAccount);
|
||||
|
||||
await tx.signAndSend(account, { signer }, ({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
toast.success('Vote submitted successfully!');
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
toast.error(`Vote failed: ${decoded.name}`);
|
||||
} else {
|
||||
toast.error(`Vote failed: ${dispatchError.toString()}`);
|
||||
}
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error voting:', err);
|
||||
toast.error('Failed to submit vote');
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const submitParliamentaryVotes = async (electionId: number) => {
|
||||
if (!api || !account || !signer) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = selectedCandidates.get(electionId) || [];
|
||||
if (candidates.length === 0) {
|
||||
toast.error('Please select at least one candidate');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setVotingElectionId(electionId);
|
||||
|
||||
const tx = api.tx.welati.voteMultiple(electionId, candidates);
|
||||
|
||||
await tx.signAndSend(account, { signer }, ({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
toast.success('Votes submitted successfully!');
|
||||
setSelectedCandidates(new Map(selectedCandidates.set(electionId, [])));
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
toast.error(`Vote failed: ${decoded.name}`);
|
||||
} else {
|
||||
toast.error(`Vote failed: ${dispatchError.toString()}`);
|
||||
}
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error voting:', err);
|
||||
toast.error('Failed to submit votes');
|
||||
setVotingElectionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatRemainingTime = (endBlock: number) => {
|
||||
const remaining = endBlock - currentBlock;
|
||||
if (remaining <= 0) return 'Ended';
|
||||
const time = blocksToTime(remaining);
|
||||
if (time.days > 0) return `${time.days}d ${time.hours}h remaining`;
|
||||
if (time.hours > 0) return `${time.hours}h ${time.minutes}m remaining`;
|
||||
return `${time.minutes}m remaining`;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'VotingPeriod': return 'bg-green-500';
|
||||
case 'CampaignPeriod': return 'bg-blue-500';
|
||||
case 'CandidacyPeriod': return 'bg-yellow-500';
|
||||
default: return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
<span className="ml-3 text-gray-400">Loading elections from blockchain...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-red-500/30 bg-red-500/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center text-red-400">
|
||||
<AlertCircle className="w-5 h-5 mr-2" />
|
||||
Error loading elections: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Live Data Indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 border-green-500 text-green-400">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Live Blockchain Data
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">Block #{currentBlock.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="active" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-gray-800/50">
|
||||
<TabsTrigger value="active">Active Elections</TabsTrigger>
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
<TabsTrigger value="results">Results</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active" className="space-y-4">
|
||||
{elections.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No active elections at this time</p>
|
||||
<p className="text-sm mt-2">Check back later for upcoming elections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
elections.map(election => (
|
||||
<Card key={election.electionId} className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-white">
|
||||
{getElectionTypeLabel(election.electionType).en}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{election.status === 'VotingPeriod'
|
||||
? `${election.totalVotes.toLocaleString()} votes cast • ${formatRemainingTime(election.votingEndBlock)}`
|
||||
: formatRemainingTime(election.candidacyEndBlock)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={`${getStatusColor(election.status)} text-white`}>
|
||||
{getElectionStatusLabel(election.status).en}
|
||||
</Badge>
|
||||
{election.userHasVoted && (
|
||||
<Badge variant="outline" className="border-green-500 text-green-400">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
You Voted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{election.status === 'VotingPeriod' && (
|
||||
<div className="space-y-4">
|
||||
{election.candidates.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No candidates registered</p>
|
||||
) : (
|
||||
<>
|
||||
{election.candidates.map(candidate => {
|
||||
const percentage = election.totalVotes > 0
|
||||
? (candidate.voteCount / election.totalVotes) * 100
|
||||
: 0;
|
||||
const isSelected = (selectedCandidates.get(election.electionId) || [])
|
||||
.includes(candidate.account);
|
||||
|
||||
return (
|
||||
<div key={candidate.account} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{candidate.account.substring(0, 8)}...{candidate.account.slice(-6)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{candidate.endorsersCount} endorsements
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-white">{percentage.toFixed(1)}%</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{candidate.voteCount.toLocaleString()} votes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
{!election.userHasVoted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
onClick={() => handleVote(election.electionId, candidate.account, election.electionType)}
|
||||
disabled={votingElectionId === election.electionId}
|
||||
className="w-full"
|
||||
>
|
||||
{votingElectionId === election.electionId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Selected
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Vote className="w-4 h-4 mr-2" />
|
||||
{election.electionType === 'Parliamentary' ? 'Select' : 'Vote'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{election.electionType === 'Parliamentary' && !election.userHasVoted && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<p className="text-sm text-gray-400 text-center mb-3">
|
||||
Select multiple candidates for parliamentary election
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => submitParliamentaryVotes(election.electionId)}
|
||||
disabled={votingElectionId === election.electionId ||
|
||||
(selectedCandidates.get(election.electionId) || []).length === 0}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{votingElectionId === election.electionId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting Votes...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Vote className="w-4 h-4 mr-2" />
|
||||
Submit {(selectedCandidates.get(election.electionId) || []).length} Vote(s)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{election.status === 'CandidacyPeriod' && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-400 mb-4">
|
||||
{election.totalCandidates} candidates registered so far
|
||||
</p>
|
||||
<Button variant="outline">
|
||||
Register as Candidate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{election.status === 'CampaignPeriod' && (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
<p>{election.totalCandidates} candidates competing</p>
|
||||
<p className="text-sm mt-2">Voting begins {formatRemainingTime(election.campaignEndBlock)}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="register">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Candidate Registration</CardTitle>
|
||||
<CardDescription>
|
||||
Register as a candidate for upcoming elections
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-amber-900/20 border border-amber-500/30 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-400">
|
||||
Requirements
|
||||
</p>
|
||||
<ul className="text-sm text-amber-300/80 mt-2 space-y-1">
|
||||
<li>• Minimum Trust Score: 300 (Parliamentary) / 600 (Presidential)</li>
|
||||
<li>• KYC Approved Status</li>
|
||||
<li>• Endorsements: 10 (Parliamentary) / 50 (Presidential)</li>
|
||||
<li>• Deposit: 1000 PEZ</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700" size="lg">
|
||||
Register as Candidate
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="results">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Election Results</CardTitle>
|
||||
<CardDescription>Historical election outcomes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{completedResults.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<Trophy className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No completed elections yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{completedResults.map((result) => (
|
||||
<div key={result.electionId} className="p-4 border border-gray-700 rounded-lg">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<p className="font-medium text-white">Election #{result.electionId}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Finalized at block #{result.finalizedAt.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-green-500 text-green-400">
|
||||
<Trophy className="w-3 h-3 mr-1" />
|
||||
Completed
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Winner(s)</span>
|
||||
<span className="font-medium text-white">
|
||||
{result.winners.length > 0
|
||||
? result.winners.map(w => `${w.substring(0, 8)}...`).join(', ')
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Total Votes</span>
|
||||
<span className="text-white">{result.totalVotes.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Turnout</span>
|
||||
<span className="text-white">{result.turnoutPercentage}%</span>
|
||||
</div>
|
||||
{result.runoffRequired && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400">
|
||||
Runoff Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElectionsInterface;
|
||||
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { FileText, Users, Trophy, CheckCircle, XCircle, Clock, Activity, Loader2, TrendingUp, Calendar } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
getElectionResults,
|
||||
getGovernanceStats,
|
||||
blocksToTime,
|
||||
getCurrentBlock,
|
||||
type ElectionResult,
|
||||
type GovernanceMetrics
|
||||
} from '@pezkuwi/lib/welati';
|
||||
|
||||
interface CompletedProposal {
|
||||
proposalId: number;
|
||||
title: string;
|
||||
proposer: string;
|
||||
status: 'Approved' | 'Rejected' | 'Expired';
|
||||
ayeVotes: number;
|
||||
nayVotes: number;
|
||||
finalizedAt: number;
|
||||
decisionType: string;
|
||||
}
|
||||
|
||||
const GovernanceHistory: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const [completedElections, setCompletedElections] = useState<ElectionResult[]>([]);
|
||||
const [completedProposals, setCompletedProposals] = useState<CompletedProposal[]>([]);
|
||||
const [stats, setStats] = useState<GovernanceMetrics | null>(null);
|
||||
const [currentBlock, setCurrentBlock] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get current block
|
||||
const block = await getCurrentBlock(api);
|
||||
setCurrentBlock(block);
|
||||
|
||||
// Get governance stats
|
||||
const governanceStats = await getGovernanceStats(api);
|
||||
setStats(governanceStats);
|
||||
|
||||
// Get completed elections
|
||||
const elections: ElectionResult[] = [];
|
||||
if (api.query.welati?.nextElectionId) {
|
||||
const nextId = await api.query.welati.nextElectionId();
|
||||
const currentId = (nextId.toJSON() as number) || 0;
|
||||
|
||||
for (let i = 0; i < currentId; i++) {
|
||||
const result = await getElectionResults(api, i);
|
||||
if (result && result.totalVotes > 0) {
|
||||
elections.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
setCompletedElections(elections.reverse());
|
||||
|
||||
// Get completed proposals
|
||||
const proposals: CompletedProposal[] = [];
|
||||
if (api.query.welati?.nextProposalId) {
|
||||
const nextId = await api.query.welati.nextProposalId();
|
||||
const currentId = (nextId.toJSON() as number) || 0;
|
||||
|
||||
for (let i = Math.max(0, currentId - 100); i < currentId; i++) {
|
||||
const proposal = await api.query.welati.activeProposals(i);
|
||||
if (proposal.isSome) {
|
||||
const data = proposal.unwrap().toJSON() as Record<string, unknown>;
|
||||
if (data.status !== 'Active') {
|
||||
proposals.push({
|
||||
proposalId: i,
|
||||
title: (data.title as string) || `Proposal #${i}`,
|
||||
proposer: (data.proposer as string) || 'Unknown',
|
||||
status: data.status as 'Approved' | 'Rejected' | 'Expired',
|
||||
ayeVotes: (data.ayeVotes as number) || 0,
|
||||
nayVotes: (data.nayVotes as number) || 0,
|
||||
finalizedAt: (data.expiresAt as number) || 0,
|
||||
decisionType: (data.decisionType as string) || 'Unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check democracy referenda
|
||||
if (api.query.democracy?.referendumInfoOf) {
|
||||
const entries = await api.query.democracy.referendumInfoOf.entries();
|
||||
for (const [key, value] of entries) {
|
||||
const refIndex = key.args[0].toNumber();
|
||||
const info = value.toJSON() as Record<string, unknown>;
|
||||
|
||||
if (info?.finished) {
|
||||
const finished = info.finished as Record<string, unknown>;
|
||||
proposals.push({
|
||||
proposalId: refIndex,
|
||||
title: `Democracy Referendum #${refIndex}`,
|
||||
proposer: 'Democracy',
|
||||
status: finished.approved ? 'Approved' : 'Rejected',
|
||||
ayeVotes: 0,
|
||||
nayVotes: 0,
|
||||
finalizedAt: (finished.end as number) || 0,
|
||||
decisionType: 'Democracy'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCompletedProposals(proposals.sort((a, b) => b.finalizedAt - a.finalizedAt));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching governance history:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHistory();
|
||||
|
||||
const interval = setInterval(fetchHistory, 60000); // Refresh every minute
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const formatBlockTime = (blockNumber: number) => {
|
||||
if (!blockNumber || blockNumber === 0) return 'Unknown';
|
||||
const blocksAgo = currentBlock - blockNumber;
|
||||
if (blocksAgo < 0) return 'Future';
|
||||
const time = blocksToTime(blocksAgo);
|
||||
if (time.days > 0) return `${time.days}d ago`;
|
||||
if (time.hours > 0) return `${time.hours}h ago`;
|
||||
return `${time.minutes}m ago`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Approved': return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
case 'Rejected': return <XCircle className="w-4 h-4 text-red-400" />;
|
||||
default: return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Approved': return 'bg-green-500/20 text-green-400';
|
||||
case 'Rejected': return 'bg-red-500/20 text-red-400';
|
||||
case 'Expired': return 'bg-yellow-500/20 text-yellow-400';
|
||||
default: return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
<span className="ml-3 text-gray-400">Loading governance history...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-red-500/30 bg-red-500/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center text-red-400">
|
||||
<XCircle className="w-5 h-5 mr-2" />
|
||||
Error: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Trophy className="w-8 h-8 text-yellow-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{stats.totalElectionsHeld}</div>
|
||||
<div className="text-sm text-gray-400">Elections Held</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-purple-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{stats.totalProposalsSubmitted}</div>
|
||||
<div className="text-sm text-gray-400">Total Proposals</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-cyan-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{stats.parliamentSize}</div>
|
||||
<div className="text-sm text-gray-400">Parliament Size</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-8 h-8 text-green-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{stats.averageTurnout}%</div>
|
||||
<div className="text-sm text-gray-400">Avg Turnout</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Data Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 border-green-500 text-green-400">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Live Blockchain Data
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">Block #{currentBlock.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="elections" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-gray-800/50">
|
||||
<TabsTrigger value="elections">Election History</TabsTrigger>
|
||||
<TabsTrigger value="proposals">Proposal History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="elections" className="space-y-4">
|
||||
{completedElections.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<Trophy className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No completed elections in history</p>
|
||||
<p className="text-sm mt-2">Election results will appear here once voting concludes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
completedElections.map((election) => (
|
||||
<Card key={election.electionId} className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-white flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||
Election #{election.electionId}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 mt-1 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Finalized {formatBlockTime(election.finalizedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="bg-green-500/20 text-green-400">
|
||||
Completed
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Winner(s):</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{election.winners.length > 0 ? (
|
||||
election.winners.map((winner, idx) => (
|
||||
<Badge key={idx} variant="outline" className="mr-1 text-xs">
|
||||
{winner.substring(0, 8)}...{winner.slice(-6)}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500">No winners</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-gray-400">Total Votes</div>
|
||||
<div className="text-white font-medium">{election.totalVotes.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-700 flex justify-between text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Turnout: </span>
|
||||
<span className="text-white">{election.turnoutPercentage}%</span>
|
||||
</div>
|
||||
{election.runoffRequired && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400">
|
||||
Runoff Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="proposals" className="space-y-4">
|
||||
{completedProposals.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No completed proposals in history</p>
|
||||
<p className="text-sm mt-2">Proposal outcomes will appear here once voting concludes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
completedProposals.map((proposal) => (
|
||||
<Card key={proposal.proposalId} className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(proposal.status)}
|
||||
<div>
|
||||
<h4 className="font-medium text-white">{proposal.title}</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Proposed by {proposal.proposer.substring(0, 8)}...
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="text-green-400">
|
||||
<CheckCircle className="w-3 h-3 inline mr-1" />
|
||||
{proposal.ayeVotes} Aye
|
||||
</span>
|
||||
<span className="text-red-400">
|
||||
<XCircle className="w-3 h-3 inline mr-1" />
|
||||
{proposal.nayVotes} Nay
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{formatBlockTime(proposal.finalizedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={getStatusColor(proposal.status)}>
|
||||
{proposal.status}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{proposal.decisionType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GovernanceHistory;
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Vote, Users, Gavel, FileText, TrendingUpIcon,
|
||||
CheckCircle,
|
||||
PieChart, Activity, Shield
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Progress } from '../ui/progress';
|
||||
import { usePezkuwi } from '../../contexts/PezkuwiContext';
|
||||
import { formatBalance } from '@pezkuwi/lib/wallet';
|
||||
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
|
||||
|
||||
interface GovernanceStats {
|
||||
activeProposals: number;
|
||||
activeElections: number;
|
||||
totalVoters: number;
|
||||
participationRate: number;
|
||||
parliamentMembers: number;
|
||||
diwanMembers: number;
|
||||
nextElection: string;
|
||||
treasuryBalance: string;
|
||||
}
|
||||
|
||||
const GovernanceOverview: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const [stats, setStats] = useState<GovernanceStats>({
|
||||
activeProposals: 0,
|
||||
activeElections: 0,
|
||||
totalVoters: 0,
|
||||
participationRate: 0,
|
||||
parliamentMembers: 0,
|
||||
diwanMembers: 0,
|
||||
nextElection: '-',
|
||||
treasuryBalance: '0 HEZ'
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGovernanceData = async () => {
|
||||
if (!api || !isApiReady) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('API not ready for governance data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('📊 Fetching governance data from blockchain...');
|
||||
setLoading(true);
|
||||
|
||||
// Fetch active referenda (proposals)
|
||||
let activeProposals = 0;
|
||||
try {
|
||||
const referendaCount = await api.query.referenda.referendumCount();
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Referenda count:', referendaCount.toNumber());
|
||||
activeProposals = referendaCount.toNumber();
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch referenda count:', err);
|
||||
}
|
||||
|
||||
// Fetch treasury balance
|
||||
let treasuryBalance = '0 HEZ';
|
||||
try {
|
||||
const treasuryAccount = await api.query.system.account(
|
||||
'5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z' // Treasury pallet address
|
||||
);
|
||||
const balance = treasuryAccount.data.free.toString();
|
||||
treasuryBalance = `${formatBalance(balance)} HEZ`;
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Treasury balance:', treasuryBalance);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch treasury balance:', err);
|
||||
}
|
||||
|
||||
// Fetch council members
|
||||
let parliamentMembers = 0;
|
||||
try {
|
||||
const members = await api.query.council.members();
|
||||
parliamentMembers = members.length;
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Council members:', parliamentMembers);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch council members:', err);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
setStats({
|
||||
activeProposals,
|
||||
activeElections: 0, // Not implemented yet
|
||||
totalVoters: 0, // Will be calculated from conviction voting
|
||||
participationRate: 0,
|
||||
parliamentMembers,
|
||||
diwanMembers: 0, // Not implemented yet
|
||||
nextElection: '-',
|
||||
treasuryBalance
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log('✅ Governance data updated:', {
|
||||
activeProposals,
|
||||
parliamentMembers,
|
||||
treasuryBalance
|
||||
});
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch governance data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGovernanceData();
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const [recentActivity] = useState([
|
||||
{ type: 'proposal', action: 'New proposal submitted', title: 'Treasury Allocation Update', time: '2 hours ago' },
|
||||
{ type: 'vote', action: 'Vote cast', title: 'Infrastructure Development Fund', time: '3 hours ago' },
|
||||
{ type: 'election', action: 'Election started', title: 'Parliamentary Elections 2024', time: '1 day ago' },
|
||||
{ type: 'approved', action: 'Proposal approved', title: 'Community Grant Program', time: '2 days ago' }
|
||||
]);
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch(type) {
|
||||
case 'proposal': return <FileText className="w-4 h-4 text-blue-400" />;
|
||||
case 'vote': return <Vote className="w-4 h-4 text-purple-400" />;
|
||||
case 'election': return <Users className="w-4 h-4 text-cyan-400" />;
|
||||
case 'approved': return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
default: return <Activity className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading governance data..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Active Proposals</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{stats.activeProposals}</p>
|
||||
<p className="text-xs text-green-400 mt-2">+3 this week</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Active Elections</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{stats.activeElections}</p>
|
||||
<p className="text-xs text-cyan-400 mt-2">Next in {stats.nextElection}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-cyan-500/10 rounded-lg">
|
||||
<Users className="w-6 h-6 text-cyan-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Participation Rate</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{stats.participationRate}%</p>
|
||||
<Progress value={stats.participationRate} className="mt-2 h-1" />
|
||||
</div>
|
||||
<div className="p-3 bg-kurdish-green/10 rounded-lg">
|
||||
<TrendingUpIcon className="w-6 h-6 text-kurdish-green" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Treasury Balance</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{stats.treasuryBalance}</p>
|
||||
<p className="text-xs text-yellow-400 mt-2">Available for proposals</p>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-500/10 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Government Bodies */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Gavel className="w-5 h-5 mr-2 text-purple-400" />
|
||||
Parliament Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Active Members</span>
|
||||
<span className="text-white font-semibold">{stats.parliamentMembers}/27</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Current Session</span>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">In Session</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Pending Votes</span>
|
||||
<span className="text-white font-semibold">5</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Quorum Status</span>
|
||||
<span className="text-green-400">Met (85%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Shield className="w-5 h-5 mr-2 text-cyan-400" />
|
||||
Dîwan (Constitutional Court)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Active Judges</span>
|
||||
<span className="text-white font-semibold">{stats.diwanMembers}/9</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Pending Reviews</span>
|
||||
<span className="text-white font-semibold">3</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Recent Decisions</span>
|
||||
<span className="text-white font-semibold">12</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Next Hearing</span>
|
||||
<span className="text-cyan-400">Tomorrow, 14:00 UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Activity className="w-5 h-5 mr-2 text-purple-400" />
|
||||
Recent Governance Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{recentActivity.map((activity, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-800/50 transition-colors">
|
||||
{getActivityIcon(activity.type)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-300">{activity.action}</p>
|
||||
<p className="text-xs text-white font-medium mt-1">{activity.title}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{activity.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Power Distribution */}
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<PieChart className="w-5 h-5 mr-2 text-purple-400" />
|
||||
Voting Power Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Direct Votes</span>
|
||||
<span className="text-white font-semibold">45%</span>
|
||||
</div>
|
||||
<Progress value={45} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Delegated Votes</span>
|
||||
<span className="text-white font-semibold">35%</span>
|
||||
</div>
|
||||
<Progress value={35} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Proxy Votes</span>
|
||||
<span className="text-white font-semibold">20%</span>
|
||||
</div>
|
||||
<Progress value={20} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GovernanceOverview;
|
||||
@@ -0,0 +1,433 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Vote, FileText, Users, CheckCircle, XCircle, Clock, Activity, Loader2, Wallet } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
|
||||
interface ProposalVote {
|
||||
proposalId: number;
|
||||
proposalTitle: string;
|
||||
vote: 'Aye' | 'Nay' | 'Abstain';
|
||||
conviction: number;
|
||||
amount: string;
|
||||
votedAt: number;
|
||||
status: 'Active' | 'Approved' | 'Rejected' | 'Expired';
|
||||
}
|
||||
|
||||
interface ElectionVote {
|
||||
electionId: number;
|
||||
electionType: string;
|
||||
candidates: string[];
|
||||
votedAt: number;
|
||||
status: 'Active' | 'Completed';
|
||||
}
|
||||
|
||||
interface DelegationInfo {
|
||||
delegateAddress: string;
|
||||
amount: string;
|
||||
conviction: number;
|
||||
tracks: string[];
|
||||
status: 'active' | 'expired';
|
||||
}
|
||||
|
||||
const MyVotes: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account, isConnected } = useWallet();
|
||||
const [proposalVotes, setProposalVotes] = useState<ProposalVote[]>([]);
|
||||
const [electionVotes, setElectionVotes] = useState<ElectionVote[]>([]);
|
||||
const [delegations, setDelegations] = useState<DelegationInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatTokenAmount = (amount: string | number) => {
|
||||
const value = typeof amount === 'string' ? BigInt(amount) : BigInt(amount);
|
||||
return formatNumber(Number(value) / 1e12, 2);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !account) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUserVotes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const votes: ProposalVote[] = [];
|
||||
const elections: ElectionVote[] = [];
|
||||
const userDelegations: DelegationInfo[] = [];
|
||||
|
||||
// Fetch democracy votes
|
||||
if (api.query.democracy?.votingOf) {
|
||||
const votingInfo = await api.query.democracy.votingOf(account);
|
||||
const data = votingInfo.toJSON() as Record<string, unknown>;
|
||||
|
||||
if (data?.direct?.votes) {
|
||||
const directVotes = data.direct.votes as Array<[number, unknown]>;
|
||||
for (const [refIndex, voteData] of directVotes) {
|
||||
const voteInfo = voteData as Record<string, unknown>;
|
||||
votes.push({
|
||||
proposalId: refIndex,
|
||||
proposalTitle: `Referendum #${refIndex}`,
|
||||
vote: voteInfo.aye ? 'Aye' : 'Nay',
|
||||
conviction: (voteInfo.conviction as number) || 0,
|
||||
amount: (voteInfo.balance as string) || '0',
|
||||
votedAt: Date.now(),
|
||||
status: 'Active'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for delegations
|
||||
if (data?.delegating) {
|
||||
const delegation = data.delegating as Record<string, unknown>;
|
||||
userDelegations.push({
|
||||
delegateAddress: delegation.target as string,
|
||||
amount: (delegation.balance as string) || '0',
|
||||
conviction: (delegation.conviction as number) || 0,
|
||||
tracks: ['All'],
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch welati election votes
|
||||
if (api.query.welati?.nextElectionId) {
|
||||
const nextId = await api.query.welati.nextElectionId();
|
||||
const currentId = (nextId.toJSON() as number) || 0;
|
||||
|
||||
for (let i = 0; i < currentId; i++) {
|
||||
const vote = await api.query.welati.electionVotes(i, account);
|
||||
if (vote.isSome) {
|
||||
const voteData = vote.unwrap().toJSON() as Record<string, unknown>;
|
||||
const election = await api.query.welati.activeElections(i);
|
||||
const electionData = election.isSome ? election.unwrap().toJSON() as Record<string, unknown> : null;
|
||||
|
||||
elections.push({
|
||||
electionId: i,
|
||||
electionType: (electionData?.electionType as string) || 'Unknown',
|
||||
candidates: (voteData.candidates as string[]) || [],
|
||||
votedAt: (voteData.votedAt as number) || 0,
|
||||
status: electionData?.status === 'Completed' ? 'Completed' : 'Active'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch welati proposal votes
|
||||
if (api.query.welati?.nextProposalId) {
|
||||
const nextId = await api.query.welati.nextProposalId();
|
||||
const currentId = (nextId.toJSON() as number) || 0;
|
||||
|
||||
for (let i = Math.max(0, currentId - 50); i < currentId; i++) {
|
||||
const vote = await api.query.welati.collectiveVotes(i, account);
|
||||
if (vote.isSome) {
|
||||
const voteData = vote.unwrap().toJSON() as Record<string, unknown>;
|
||||
const proposal = await api.query.welati.activeProposals(i);
|
||||
const proposalData = proposal.isSome ? proposal.unwrap().toJSON() as Record<string, unknown> : null;
|
||||
|
||||
votes.push({
|
||||
proposalId: i,
|
||||
proposalTitle: (proposalData?.title as string) || `Proposal #${i}`,
|
||||
vote: voteData.vote as 'Aye' | 'Nay' | 'Abstain',
|
||||
conviction: 1,
|
||||
amount: '0',
|
||||
votedAt: (voteData.votedAt as number) || 0,
|
||||
status: (proposalData?.status as 'Active' | 'Approved' | 'Rejected' | 'Expired') || 'Active'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProposalVotes(votes);
|
||||
setElectionVotes(elections);
|
||||
setDelegations(userDelegations);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching user votes:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch voting history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserVotes();
|
||||
|
||||
const interval = setInterval(fetchUserVotes, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, account]);
|
||||
|
||||
const getVoteIcon = (vote: string) => {
|
||||
switch (vote) {
|
||||
case 'Aye': return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
case 'Nay': return <XCircle className="w-4 h-4 text-red-400" />;
|
||||
default: return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getVoteColor = (vote: string) => {
|
||||
switch (vote) {
|
||||
case 'Aye': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'Nay': return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Active': return 'bg-blue-500/20 text-blue-400';
|
||||
case 'Approved': return 'bg-green-500/20 text-green-400';
|
||||
case 'Rejected': return 'bg-red-500/20 text-red-400';
|
||||
case 'Completed': return 'bg-purple-500/20 text-purple-400';
|
||||
default: return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12">
|
||||
<Wallet className="w-16 h-16 mx-auto mb-4 text-gray-600" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Connect Your Wallet</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Connect your wallet to view your voting history and delegations
|
||||
</p>
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
<span className="ml-3 text-gray-400">Loading your voting history...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-red-500/30 bg-red-500/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center text-red-400">
|
||||
<XCircle className="w-5 h-5 mr-2" />
|
||||
Error: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalVotes = proposalVotes.length + electionVotes.length;
|
||||
const activeVotes = proposalVotes.filter(v => v.status === 'Active').length +
|
||||
electionVotes.filter(v => v.status === 'Active').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Vote className="w-8 h-8 text-green-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{totalVotes}</div>
|
||||
<div className="text-sm text-gray-400">Total Votes</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{activeVotes}</div>
|
||||
<div className="text-sm text-gray-400">Active Votes</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-purple-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{proposalVotes.length}</div>
|
||||
<div className="text-sm text-gray-400">Proposal Votes</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-cyan-500" />
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{electionVotes.length}</div>
|
||||
<div className="text-sm text-gray-400">Election Votes</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Live Data Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 border-green-500 text-green-400">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Live Blockchain Data
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
Connected as {account?.substring(0, 8)}...{account?.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="proposals" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-gray-800/50">
|
||||
<TabsTrigger value="proposals">Proposal Votes</TabsTrigger>
|
||||
<TabsTrigger value="elections">Election Votes</TabsTrigger>
|
||||
<TabsTrigger value="delegations">My Delegations</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proposals" className="space-y-4">
|
||||
{proposalVotes.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>You have not voted on any proposals yet</p>
|
||||
<p className="text-sm mt-2">Check the Proposals tab to participate in governance</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
proposalVotes.map((vote) => (
|
||||
<Card key={vote.proposalId} className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{getVoteIcon(vote.vote)}
|
||||
<div>
|
||||
<h4 className="font-medium text-white">{vote.proposalTitle}</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Conviction: {vote.conviction}x •
|
||||
Amount: {formatTokenAmount(vote.amount)} HEZ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={getVoteColor(vote.vote)}>
|
||||
{vote.vote}
|
||||
</Badge>
|
||||
<Badge className={getStatusColor(vote.status)}>
|
||||
{vote.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="elections" className="space-y-4">
|
||||
{electionVotes.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>You have not voted in any elections yet</p>
|
||||
<p className="text-sm mt-2">Check the Elections tab to participate</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
electionVotes.map((vote) => (
|
||||
<Card key={vote.electionId} className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-white">{vote.electionType} Election</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Election #{vote.electionId} •
|
||||
{vote.candidates.length} candidate(s) selected
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{vote.candidates.map((candidate, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{candidate.substring(0, 8)}...
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={getStatusColor(vote.status)}>
|
||||
{vote.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="delegations" className="space-y-4">
|
||||
{delegations.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>You have not delegated your voting power</p>
|
||||
<p className="text-sm mt-2">Check the Delegation tab to delegate your votes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
delegations.map((delegation, idx) => (
|
||||
<Card key={idx} className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-white">
|
||||
Delegated to {delegation.delegateAddress.substring(0, 8)}...{delegation.delegateAddress.slice(-6)}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Amount: {formatTokenAmount(delegation.amount)} HEZ •
|
||||
Conviction: {delegation.conviction}x
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{delegation.tracks.map((track, tidx) => (
|
||||
<Badge key={tidx} variant="secondary" className="text-xs">
|
||||
{track}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={delegation.status === 'active'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-500/20 text-gray-400'}>
|
||||
{delegation.status}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" className="text-xs">
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyVotes;
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Clock, Users, AlertCircle, Activity } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Progress } from '../ui/progress';
|
||||
import { Alert, AlertDescription } from '../ui/alert';
|
||||
import { useGovernance } from '@/hooks/useGovernance';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
|
||||
|
||||
interface Proposal {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
proposer: string;
|
||||
type: 'treasury' | 'executive' | 'constitutional' | 'simple';
|
||||
status: 'active' | 'passed' | 'rejected' | 'pending';
|
||||
ayeVotes: number;
|
||||
nayVotes: number;
|
||||
totalVotes: number;
|
||||
quorum: number;
|
||||
deadline: string;
|
||||
requestedAmount?: string;
|
||||
}
|
||||
|
||||
const ProposalsList: React.FC = () => {
|
||||
const { proposals: treasuryProposals, referenda, loading, error } = useGovernance();
|
||||
|
||||
// Format token amounts from blockchain units (12 decimals for HEZ)
|
||||
const formatTokenAmount = (amount: string) => {
|
||||
const value = BigInt(amount);
|
||||
return formatNumber(Number(value) / 1e12, 2);
|
||||
};
|
||||
|
||||
// Convert blockchain data to UI format
|
||||
const proposals: Proposal[] = [
|
||||
// Treasury proposals
|
||||
...treasuryProposals.map(p => ({
|
||||
id: p.proposalIndex,
|
||||
title: `Treasury Proposal #${p.proposalIndex}`,
|
||||
description: `Requesting ${formatTokenAmount(p.value)} HEZ for ${p.beneficiary.substring(0, 10)}...`,
|
||||
proposer: p.proposer,
|
||||
type: 'treasury' as const,
|
||||
status: p.status as 'active' | 'passed' | 'rejected' | 'pending',
|
||||
ayeVotes: 0, // Treasury proposals don't have votes until they become referenda
|
||||
nayVotes: 0,
|
||||
totalVotes: 0,
|
||||
quorum: 0,
|
||||
deadline: 'Pending referendum',
|
||||
requestedAmount: `${formatTokenAmount(p.value)} HEZ`
|
||||
})),
|
||||
// Democracy referenda
|
||||
...referenda.map(r => ({
|
||||
id: r.index,
|
||||
title: `Referendum #${r.index}`,
|
||||
description: `Voting on proposal with ${r.threshold} threshold`,
|
||||
proposer: 'Democracy',
|
||||
type: 'executive' as const,
|
||||
status: r.status as 'active' | 'passed' | 'rejected' | 'pending',
|
||||
ayeVotes: Number(BigInt(r.ayeVotes) / BigInt(1e12)),
|
||||
nayVotes: Number(BigInt(r.nayVotes) / BigInt(1e12)),
|
||||
totalVotes: Number((BigInt(r.ayeVotes) + BigInt(r.nayVotes)) / BigInt(1e12)),
|
||||
quorum: 50,
|
||||
deadline: `Block ${r.end}`,
|
||||
}))
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch(status) {
|
||||
case 'active': return <Badge className="bg-blue-500/10 text-blue-400">Active</Badge>;
|
||||
case 'passed': return <Badge className="bg-green-500/10 text-green-400">Passed</Badge>;
|
||||
case 'rejected': return <Badge className="bg-red-500/10 text-red-400">Rejected</Badge>;
|
||||
default: return <Badge className="bg-gray-500/10 text-gray-400">Pending</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
switch(type) {
|
||||
case 'treasury': return <Badge className="bg-yellow-500/10 text-yellow-400">Treasury</Badge>;
|
||||
case 'executive': return <Badge className="bg-kurdish-red/10 text-kurdish-red">Executive</Badge>;
|
||||
case 'constitutional': return <Badge className="bg-cyan-500/10 text-cyan-400">Constitutional</Badge>;
|
||||
default: return <Badge className="bg-gray-500/10 text-gray-400">Simple</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading proposals from blockchain..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load proposals: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Live Data Badge */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Live Blockchain Data
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{proposals.length} active proposals & referenda
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{proposals.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="pt-6 text-center text-gray-500">
|
||||
No active proposals or referenda found on the blockchain.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
proposals.map((proposal) => {
|
||||
const ayePercentage = (proposal.ayeVotes / proposal.totalVotes) * 100;
|
||||
const nayPercentage = (proposal.nayVotes / proposal.totalVotes) * 100;
|
||||
const quorumReached = (proposal.totalVotes / 300) * 100 >= proposal.quorum;
|
||||
|
||||
return (
|
||||
<Card key={proposal.id} className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-400 text-sm">#{proposal.id}</span>
|
||||
{getTypeBadge(proposal.type)}
|
||||
{getStatusBadge(proposal.status)}
|
||||
</div>
|
||||
<CardTitle className="text-white text-lg">{proposal.title}</CardTitle>
|
||||
<p className="text-gray-400 text-sm">{proposal.description}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center text-gray-400 text-sm">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{proposal.deadline}
|
||||
</div>
|
||||
{proposal.requestedAmount && (
|
||||
<div className="mt-2 text-yellow-400 font-semibold">
|
||||
{proposal.requestedAmount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Voting Progress</span>
|
||||
<span className="text-white">{proposal.totalVotes} votes</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-green-400 text-xs w-12">Aye</span>
|
||||
<Progress value={ayePercentage} className="flex-1 h-2" />
|
||||
<span className="text-white text-sm w-12 text-right">{ayePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-red-400 text-xs w-12">Nay</span>
|
||||
<Progress value={nayPercentage} className="flex-1 h-2" />
|
||||
<span className="text-white text-sm w-12 text-right">{nayPercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-800">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-1 text-gray-400" />
|
||||
<span className="text-gray-400">Proposer: {proposal.proposer}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{quorumReached ? (
|
||||
<span className="text-green-400">✓ Quorum reached</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">⚠ Quorum: {proposal.quorum}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" className="border-gray-700">
|
||||
View Details
|
||||
</Button>
|
||||
<Button size="sm" className="bg-kurdish-green hover:bg-kurdish-green/80">
|
||||
Cast Vote
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProposalsList;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'system';
|
||||
read: boolean;
|
||||
action_url?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadNotifications();
|
||||
|
||||
subscribeToNotifications();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user]);
|
||||
|
||||
const loadNotifications = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (data) {
|
||||
setNotifications(data);
|
||||
setUnreadCount(data.filter(n => !n.read).length);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToNotifications = () => {
|
||||
const channel = supabase
|
||||
.channel('notifications')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `user_id=eq.${user?.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
setNotifications(prev => [payload.new as Notification, ...prev]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
};
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'markRead',
|
||||
userId: user?.id,
|
||||
notificationId
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'markAllRead',
|
||||
userId: user?.id
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
const deleteNotification = async (notificationId: string) => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'delete',
|
||||
userId: user?.id,
|
||||
notificationId
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return 'text-green-600';
|
||||
case 'warning': return 'text-yellow-600';
|
||||
case 'error': return 'text-red-600';
|
||||
case 'system': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 hover:bg-muted/50 transition-colors ${
|
||||
!notification.read ? 'bg-muted/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${getTypeColor(notification.type)}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, MessageCircle, AtSign, Heart, Award, TrendingUp, X, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWebSocket } from '@/contexts/WebSocketContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'mention' | 'reply' | 'vote' | 'badge' | 'proposal';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
sender?: {
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const NotificationCenter: React.FC = () => {
|
||||
const { subscribe, unsubscribe } = useWebSocket();
|
||||
const { toast } = useToast();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [settings, setSettings] = useState({
|
||||
mentions: true,
|
||||
replies: true,
|
||||
votes: true,
|
||||
badges: true,
|
||||
proposals: true,
|
||||
pushEnabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Request notification permission
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
// Subscribe to WebSocket events
|
||||
const handleMention = (data: Record<string, unknown>) => {
|
||||
const notification: Notification = {
|
||||
id: Date.now().toString(),
|
||||
type: 'mention',
|
||||
title: 'You were mentioned',
|
||||
message: `${data.sender} mentioned you in a discussion`,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
actionUrl: data.url,
|
||||
sender: data.senderInfo,
|
||||
};
|
||||
addNotification(notification);
|
||||
};
|
||||
|
||||
const handleReply = (data: Record<string, unknown>) => {
|
||||
const notification: Notification = {
|
||||
id: Date.now().toString(),
|
||||
type: 'reply',
|
||||
title: 'New reply',
|
||||
message: `${data.sender} replied to your comment`,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
actionUrl: data.url,
|
||||
sender: data.senderInfo,
|
||||
};
|
||||
addNotification(notification);
|
||||
};
|
||||
|
||||
subscribe('mention', handleMention);
|
||||
subscribe('reply', handleReply);
|
||||
|
||||
return () => {
|
||||
unsubscribe('mention', handleMention);
|
||||
unsubscribe('reply', handleReply);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subscribe, unsubscribe]);
|
||||
|
||||
const addNotification = (notification: Notification) => {
|
||||
setNotifications(prev => [notification, ...prev]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
|
||||
// Show toast
|
||||
toast({
|
||||
title: notification.title,
|
||||
description: notification.message,
|
||||
});
|
||||
|
||||
// Show push notification if enabled
|
||||
if (settings.pushEnabled && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(notification.title, {
|
||||
body: notification.message,
|
||||
icon: '/logo.png',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = (id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'mention': return <AtSign className="h-4 w-4" />;
|
||||
case 'reply': return <MessageCircle className="h-4 w-4" />;
|
||||
case 'vote': return <Heart className="h-4 w-4" />;
|
||||
case 'badge': return <Award className="h-4 w-4" />;
|
||||
case 'proposal': return <TrendingUp className="h-4 w-4" />;
|
||||
default: return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<Card className="absolute right-0 top-12 w-96 z-50">
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="unread">Unread</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="p-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{notifications.length} notifications
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={markAllAsRead}>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 border-b hover:bg-accent cursor-pointer ${
|
||||
!notification.read ? 'bg-accent/50' : ''
|
||||
}`}
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{notification.title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{new Date(notification.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unread" className="p-0">
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.filter(n => !n.read).map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="p-4 border-b hover:bg-accent cursor-pointer bg-accent/50"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{notification.title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="push">Push Notifications</Label>
|
||||
<Switch
|
||||
id="push"
|
||||
checked={settings.pushEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && 'Notification' in window) {
|
||||
Notification.requestPermission().then(permission => {
|
||||
setSettings(prev => ({ ...prev, pushEnabled: permission === 'granted' }));
|
||||
});
|
||||
} else {
|
||||
setSettings(prev => ({ ...prev, pushEnabled: checked }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="mentions">Mentions</Label>
|
||||
<Switch
|
||||
id="mentions"
|
||||
checked={settings.mentions}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings(prev => ({ ...prev, mentions: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="replies">Replies</Label>
|
||||
<Switch
|
||||
id="replies"
|
||||
checked={settings.replies}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings(prev => ({ ...prev, replies: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Loader2, Shield, Zap } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { TradeModal } from './TradeModal';
|
||||
import { MerchantTierBadge } from './MerchantTierBadge';
|
||||
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { P2PFilters } from './types';
|
||||
|
||||
interface AdListProps {
|
||||
type: 'buy' | 'sell' | 'my-ads';
|
||||
filters?: P2PFilters;
|
||||
}
|
||||
|
||||
interface OfferWithReputation extends P2PFiatOffer {
|
||||
seller_reputation?: P2PReputation;
|
||||
payment_method_name?: string;
|
||||
merchant_tier?: 'lite' | 'super' | 'diamond';
|
||||
}
|
||||
|
||||
export function AdList({ type, filters }: AdListProps) {
|
||||
const { user } = useAuth();
|
||||
const [offers, setOffers] = useState<OfferWithReputation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOffer, setSelectedOffer] = useState<OfferWithReputation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOffers();
|
||||
|
||||
// Refresh data when user returns to the tab (visibility change)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchOffers();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, user, filters]);
|
||||
|
||||
const fetchOffers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let offersData: P2PFiatOffer[] = [];
|
||||
|
||||
// Build base query
|
||||
let query = supabase.from('p2p_fiat_offers').select('*');
|
||||
|
||||
if (type === 'buy') {
|
||||
// Buy tab = show SELL offers (user wants to buy from sellers)
|
||||
query = query.eq('ad_type', 'sell').eq('status', 'open').gt('remaining_amount', 0);
|
||||
} else if (type === 'sell') {
|
||||
// Sell tab = show BUY offers (user wants to sell to buyers)
|
||||
query = query.eq('ad_type', 'buy').eq('status', 'open').gt('remaining_amount', 0);
|
||||
} else if (type === 'my-ads' && user) {
|
||||
// My offers - show all of user's offers
|
||||
query = query.eq('seller_id', user.id);
|
||||
}
|
||||
|
||||
// Apply filters if provided
|
||||
if (filters) {
|
||||
// Token filter
|
||||
if (filters.token && filters.token !== 'all') {
|
||||
query = query.eq('token', filters.token);
|
||||
}
|
||||
|
||||
// Fiat currency filter
|
||||
if (filters.fiatCurrency && filters.fiatCurrency !== 'all') {
|
||||
query = query.eq('fiat_currency', filters.fiatCurrency);
|
||||
}
|
||||
|
||||
// Payment method filter
|
||||
if (filters.paymentMethods && filters.paymentMethods.length > 0) {
|
||||
query = query.in('payment_method_id', filters.paymentMethods);
|
||||
}
|
||||
|
||||
// Amount range filter
|
||||
if (filters.minAmount !== null) {
|
||||
query = query.gte('remaining_amount', filters.minAmount);
|
||||
}
|
||||
if (filters.maxAmount !== null) {
|
||||
query = query.lte('remaining_amount', filters.maxAmount);
|
||||
}
|
||||
|
||||
// Sort order
|
||||
const sortColumn = filters.sortBy === 'price' ? 'price_per_unit' :
|
||||
filters.sortBy === 'completion_rate' ? 'created_at' :
|
||||
filters.sortBy === 'trades' ? 'created_at' :
|
||||
'created_at';
|
||||
query = query.order(sortColumn, { ascending: filters.sortOrder === 'asc' });
|
||||
} else {
|
||||
query = query.order('created_at', { ascending: false });
|
||||
}
|
||||
|
||||
const { data } = await query;
|
||||
offersData = data || [];
|
||||
|
||||
// Enrich with reputation, payment method, and merchant tier
|
||||
const enrichedOffers = await Promise.all(
|
||||
offersData.map(async (offer) => {
|
||||
const [reputation, paymentMethod, merchantTier] = await Promise.all([
|
||||
getUserReputation(offer.seller_id),
|
||||
supabase
|
||||
.from('payment_methods')
|
||||
.select('method_name')
|
||||
.eq('id', offer.payment_method_id)
|
||||
.single(),
|
||||
supabase
|
||||
.from('p2p_merchant_tiers')
|
||||
.select('tier')
|
||||
.eq('user_id', offer.seller_id)
|
||||
.single()
|
||||
]);
|
||||
|
||||
return {
|
||||
...offer,
|
||||
seller_reputation: reputation || undefined,
|
||||
payment_method_name: paymentMethod.data?.method_name,
|
||||
merchant_tier: merchantTier.data?.tier as 'lite' | 'super' | 'diamond' | undefined
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Apply client-side filters (completion rate, merchant tier)
|
||||
let filteredOffers = enrichedOffers;
|
||||
|
||||
if (filters) {
|
||||
// Completion rate filter (needs reputation data)
|
||||
if (filters.minCompletionRate > 0) {
|
||||
filteredOffers = filteredOffers.filter(offer => {
|
||||
if (!offer.seller_reputation) return false;
|
||||
const rate = (offer.seller_reputation.completed_trades / (offer.seller_reputation.total_trades || 1)) * 100;
|
||||
return rate >= filters.minCompletionRate;
|
||||
});
|
||||
}
|
||||
|
||||
// Merchant tier filter
|
||||
if (filters.merchantTiers && filters.merchantTiers.length > 0) {
|
||||
filteredOffers = filteredOffers.filter(offer => {
|
||||
if (!offer.merchant_tier) return false;
|
||||
// If super is selected, include super and diamond
|
||||
// If diamond is selected, include only diamond
|
||||
if (filters.merchantTiers.includes('diamond')) {
|
||||
return offer.merchant_tier === 'diamond';
|
||||
}
|
||||
if (filters.merchantTiers.includes('super')) {
|
||||
return offer.merchant_tier === 'super' || offer.merchant_tier === 'diamond';
|
||||
}
|
||||
return filters.merchantTiers.includes(offer.merchant_tier);
|
||||
});
|
||||
}
|
||||
|
||||
// Verified only filter
|
||||
if (filters.verifiedOnly) {
|
||||
filteredOffers = filteredOffers.filter(offer => offer.seller_reputation?.verified_merchant);
|
||||
}
|
||||
}
|
||||
|
||||
setOffers(filteredOffers);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Fetch offers error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (offers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">
|
||||
{type === 'my-ads' ? 'You have no active offers' : 'No offers available'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{offers.map(offer => (
|
||||
<Card key={offer.id} className="bg-gray-900 border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 items-center">
|
||||
{/* Seller Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback className="bg-green-500/20 text-green-400">
|
||||
{offer.seller_wallet.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-white">
|
||||
{offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)}
|
||||
</p>
|
||||
{offer.merchant_tier && (
|
||||
<MerchantTierBadge tier={offer.merchant_tier} size="sm" />
|
||||
)}
|
||||
{offer.seller_reputation?.verified_merchant && (
|
||||
<Shield className="w-4 h-4 text-blue-400" title="Verified Merchant" />
|
||||
)}
|
||||
{offer.seller_reputation?.fast_trader && (
|
||||
<Zap className="w-4 h-4 text-yellow-400" title="Fast Trader" />
|
||||
)}
|
||||
</div>
|
||||
{offer.seller_reputation && (
|
||||
<p className="text-sm text-gray-400">
|
||||
{offer.seller_reputation.completed_trades} trades • {' '}
|
||||
{((offer.seller_reputation.completed_trades / (offer.seller_reputation.total_trades || 1)) * 100).toFixed(0)}% completion
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Price</p>
|
||||
<p className="text-xl font-bold text-green-400">
|
||||
{offer.price_per_unit.toFixed(2)} {offer.fiat_currency}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Available</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{offer.remaining_amount} {offer.token}
|
||||
</p>
|
||||
{offer.min_order_amount && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Min: {offer.min_order_amount} {offer.token}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Payment</p>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{offer.payment_method_name || 'N/A'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{offer.time_limit_minutes} min limit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{offer.seller_id === user?.id && type !== 'my-ads' && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-400 border-blue-500/30">
|
||||
Your Ad
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setSelectedOffer(offer)}
|
||||
disabled={type === 'my-ads' || offer.seller_id === user?.id}
|
||||
className="w-full md:w-auto"
|
||||
title={offer.seller_id === user?.id ? "You can't trade with your own ad" : ''}
|
||||
>
|
||||
{type === 'buy' ? 'Buy' : 'Sell'} {offer.token}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge for my-ads */}
|
||||
{type === 'my-ads' && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={offer.status === 'open' ? 'default' : 'secondary'}
|
||||
>
|
||||
{offer.status.toUpperCase()}
|
||||
</Badge>
|
||||
<p className="text-sm text-gray-400">
|
||||
Created: {new Date(offer.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{selectedOffer && (
|
||||
<TradeModal
|
||||
offer={selectedOffer}
|
||||
onClose={() => {
|
||||
setSelectedOffer(null);
|
||||
fetchOffers(); // Refresh list
|
||||
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Block Trade Component - OKX-Style OTC Trading
|
||||
*
|
||||
* Block trades are for large volume trades that are handled differently
|
||||
* from regular P2P trades. They offer:
|
||||
* - Custom pricing negotiation
|
||||
* - Dedicated support
|
||||
* - Multi-tranche settlements
|
||||
* - Enhanced privacy
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Blocks, Shield, Clock, Lock, MessageSquare, ChevronRight,
|
||||
Building2, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat';
|
||||
|
||||
interface BlockTradeRequest {
|
||||
id: string;
|
||||
type: 'buy' | 'sell';
|
||||
token: CryptoToken;
|
||||
fiat_currency: FiatCurrency;
|
||||
amount: number;
|
||||
target_price?: number;
|
||||
message?: string;
|
||||
status: 'pending' | 'negotiating' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_TOKENS: CryptoToken[] = ['HEZ', 'PEZ'];
|
||||
|
||||
// All supported fiat currencies including Kurdish Diaspora countries
|
||||
const SUPPORTED_FIATS: { code: FiatCurrency; name: string; symbol: string; region: string }[] = [
|
||||
// Primary regions (Kurdistan & neighboring)
|
||||
{ code: 'TRY', name: 'Turkish Lira', symbol: '₺', region: 'Bakur' },
|
||||
{ code: 'IQD', name: 'Iraqi Dinar', symbol: 'د.ع', region: 'Başûr' },
|
||||
{ code: 'IRR', name: 'Iranian Rial', symbol: '﷼', region: 'Rojhilat' },
|
||||
// Eurozone diaspora
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€', region: 'EU' },
|
||||
// Other diaspora regions
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$', region: 'USA' },
|
||||
{ code: 'GBP', name: 'British Pound', symbol: '£', region: 'UK' },
|
||||
{ code: 'SEK', name: 'Swedish Krona', symbol: 'kr', region: 'Sweden' },
|
||||
{ code: 'CHF', name: 'Swiss Franc', symbol: 'Fr.', region: 'Switzerland' },
|
||||
{ code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', region: 'Norway' },
|
||||
{ code: 'DKK', name: 'Danish Krone', symbol: 'kr', region: 'Denmark' },
|
||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$', region: 'Australia' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', region: 'Canada' },
|
||||
];
|
||||
|
||||
// Minimum amounts for block trade (in USD equivalent)
|
||||
const MINIMUM_BLOCK_AMOUNTS: Record<CryptoToken, number> = {
|
||||
HEZ: 10000, // 10,000 HEZ minimum
|
||||
PEZ: 50000, // 50,000 PEZ minimum
|
||||
};
|
||||
|
||||
export function BlockTrade() {
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [type, setType] = useState<'buy' | 'sell'>('buy');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
const [fiat, setFiat] = useState<FiatCurrency>('USD');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [targetPrice, setTargetPrice] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [requests, setRequests] = useState<BlockTradeRequest[]>([]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || '';
|
||||
const minAmount = MINIMUM_BLOCK_AMOUNTS[token];
|
||||
|
||||
// Fetch user's block trade requests
|
||||
React.useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchRequests = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_block_trade_requests')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
setRequests(data);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRequests();
|
||||
}, [user]);
|
||||
|
||||
const handleSubmitRequest = async () => {
|
||||
if (!user) {
|
||||
toast.error('Please login to submit a block trade request');
|
||||
return;
|
||||
}
|
||||
|
||||
const amountNum = parseFloat(amount);
|
||||
if (isNaN(amountNum) || amountNum < minAmount) {
|
||||
toast.error(`Minimum amount for ${token} block trade is ${minAmount.toLocaleString()} ${token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_block_trade_requests')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
type,
|
||||
token,
|
||||
fiat_currency: fiat,
|
||||
amount: amountNum,
|
||||
target_price: targetPrice ? parseFloat(targetPrice) : null,
|
||||
message: message || null,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Block trade request submitted! Our OTC desk will contact you within 24 hours.');
|
||||
setShowRequestModal(false);
|
||||
setAmount('');
|
||||
setTargetPrice('');
|
||||
setMessage('');
|
||||
|
||||
// Add to local state
|
||||
setRequests(prev => [data, ...prev]);
|
||||
} catch (err) {
|
||||
console.error('Block trade request error:', err);
|
||||
toast.error('Failed to submit request');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: BlockTradeRequest['status']) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
negotiating: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
approved: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
in_progress: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
completed: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
};
|
||||
return styles[status] || styles.pending;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gradient-to-br from-purple-500/10 to-blue-500/10 border-purple-500/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||
<Blocks className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg text-white">Block Trade (OTC)</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Large volume trades with custom pricing
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30">
|
||||
VIP
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Lock className="w-4 h-4 text-purple-400" />
|
||||
<span>Private Negotiation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Shield className="w-4 h-4 text-green-400" />
|
||||
<span>Escrow Protected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Building2 className="w-4 h-4 text-blue-400" />
|
||||
<span>Dedicated Support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Clock className="w-4 h-4 text-yellow-400" />
|
||||
<span>Flexible Settlement</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minimum Amounts Info */}
|
||||
<div className="p-3 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 mb-2">Minimum Block Trade Amounts:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(MINIMUM_BLOCK_AMOUNTS).map(([t, min]) => (
|
||||
<Badge key={t} variant="outline" className="border-gray-700 text-gray-300">
|
||||
{min.toLocaleString()} {t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Button */}
|
||||
<Button
|
||||
className="w-full bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Request Block Trade
|
||||
<ChevronRight className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
|
||||
{/* Active Requests */}
|
||||
{requests.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500">Your Requests:</p>
|
||||
{requests.slice(0, 3).map(req => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="p-2 bg-gray-800/50 rounded-lg flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${req.type === 'buy' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{req.type.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-white">
|
||||
{req.amount.toLocaleString()} {req.token}
|
||||
</span>
|
||||
</div>
|
||||
<Badge className={getStatusBadge(req.status)}>
|
||||
{req.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Request Modal */}
|
||||
<Dialog open={showRequestModal} onOpenChange={setShowRequestModal}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Blocks className="w-5 h-5 text-purple-400" />
|
||||
Block Trade Request
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Submit a request for our OTC desk to handle your large volume trade.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Buy/Sell Toggle */}
|
||||
<Tabs value={type} onValueChange={(v) => setType(v as 'buy' | 'sell')}>
|
||||
<TabsList className="grid w-full grid-cols-2 bg-gray-800">
|
||||
<TabsTrigger value="buy" className="data-[state=active]:bg-green-600">
|
||||
Buy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sell" className="data-[state=active]:bg-red-600">
|
||||
Sell
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Token & Fiat */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_TOKENS.map(t => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Currency</Label>
|
||||
<Select value={fiat} onValueChange={(v) => setFiat(v as FiatCurrency)}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_FIATS.map(f => (
|
||||
<SelectItem key={f.code} value={f.code}>
|
||||
{f.symbol} {f.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Amount ({token})</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`Min: ${minAmount.toLocaleString()}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Minimum: {minAmount.toLocaleString()} {token}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Target Price (Optional) */}
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Target Price (Optional)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Your desired price per unit"
|
||||
value={targetPrice}
|
||||
onChange={(e) => setTargetPrice(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 pr-16"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">
|
||||
{fiatSymbol}/{token}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Additional Details (Optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Settlement preferences, timeline, payment methods..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5" />
|
||||
<p className="text-xs text-yellow-400">
|
||||
Block trades require KYC verification and may take 24-48 hours to process.
|
||||
Our OTC desk will contact you via email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRequestModal(false)}
|
||||
className="border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={isSubmitting || !amount || parseFloat(amount) < minAmount}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import {
|
||||
getPaymentMethods,
|
||||
validatePaymentDetails,
|
||||
type PaymentMethod,
|
||||
type FiatCurrency,
|
||||
type CryptoToken
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface CreateAdProps {
|
||||
onAdCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateAd({ onAdCreated }: CreateAdProps) {
|
||||
const { user } = useAuth();
|
||||
const { account } = useWallet();
|
||||
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form fields
|
||||
const [adType, setAdType] = useState<'buy' | 'sell'>('sell');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
const [amountCrypto, setAmountCrypto] = useState('');
|
||||
const [fiatCurrency, setFiatCurrency] = useState<FiatCurrency>('TRY');
|
||||
const [fiatAmount, setFiatAmount] = useState('');
|
||||
const [paymentDetails, setPaymentDetails] = useState<Record<string, string>>({});
|
||||
const [timeLimit, setTimeLimit] = useState(30);
|
||||
const [minOrderAmount, setMinOrderAmount] = useState('');
|
||||
const [maxOrderAmount, setMaxOrderAmount] = useState('');
|
||||
|
||||
// Load payment methods when currency changes
|
||||
useEffect(() => {
|
||||
const loadPaymentMethods = async () => {
|
||||
const methods = await getPaymentMethods(fiatCurrency);
|
||||
setPaymentMethods(methods);
|
||||
setSelectedPaymentMethod(null);
|
||||
setPaymentDetails({});
|
||||
};
|
||||
loadPaymentMethods();
|
||||
}, [fiatCurrency]);
|
||||
|
||||
// Calculate price per unit
|
||||
const pricePerUnit = amountCrypto && fiatAmount
|
||||
? (parseFloat(fiatAmount) / parseFloat(amountCrypto)).toFixed(2)
|
||||
: '0';
|
||||
|
||||
const handlePaymentMethodChange = (methodId: string) => {
|
||||
const method = paymentMethods.find(m => m.id === methodId);
|
||||
setSelectedPaymentMethod(method || null);
|
||||
|
||||
// Initialize payment details with empty values
|
||||
if (method) {
|
||||
const initialDetails: Record<string, string> = {};
|
||||
Object.keys(method.fields).forEach(field => {
|
||||
initialDetails[field] = '';
|
||||
});
|
||||
setPaymentDetails(initialDetails);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentDetailChange = (field: string, value: string) => {
|
||||
setPaymentDetails(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleCreateAd = async () => {
|
||||
console.log('🔥 handleCreateAd called', { account, user: user?.id });
|
||||
|
||||
if (!account || !user) {
|
||||
toast.error('Please connect your wallet and log in');
|
||||
console.log('❌ No account or user', { account, user });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPaymentMethod) {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate payment details
|
||||
const validation = validatePaymentDetails(
|
||||
paymentDetails,
|
||||
selectedPaymentMethod.validation_rules
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
const firstError = Object.values(validation.errors)[0];
|
||||
toast.error(firstError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate amounts
|
||||
const cryptoAmt = parseFloat(amountCrypto);
|
||||
const fiatAmt = parseFloat(fiatAmount);
|
||||
|
||||
if (!cryptoAmt || cryptoAmt <= 0) {
|
||||
toast.error('Invalid crypto amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fiatAmt || fiatAmt <= 0) {
|
||||
toast.error('Invalid fiat amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod.min_trade_amount && fiatAmt < selectedPaymentMethod.min_trade_amount) {
|
||||
toast.error(`Minimum trade amount: ${selectedPaymentMethod.min_trade_amount} ${fiatCurrency}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod.max_trade_amount && fiatAmt > selectedPaymentMethod.max_trade_amount) {
|
||||
toast.error(`Maximum trade amount: ${selectedPaymentMethod.max_trade_amount} ${fiatCurrency}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Insert offer into Supabase
|
||||
// Note: payment_details_encrypted is stored as JSON string (encryption handled server-side in prod)
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.insert({
|
||||
seller_id: user.id,
|
||||
seller_wallet: account,
|
||||
ad_type: adType,
|
||||
token,
|
||||
amount_crypto: cryptoAmt,
|
||||
remaining_amount: cryptoAmt,
|
||||
fiat_currency: fiatCurrency,
|
||||
fiat_amount: fiatAmt,
|
||||
payment_method_id: selectedPaymentMethod.id,
|
||||
payment_details_encrypted: JSON.stringify(paymentDetails),
|
||||
time_limit_minutes: timeLimit,
|
||||
min_order_amount: minOrderAmount ? parseFloat(minOrderAmount) : null,
|
||||
max_order_amount: maxOrderAmount ? parseFloat(maxOrderAmount) : null,
|
||||
status: 'open'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Supabase error:', error);
|
||||
toast.error(error.message || 'Failed to create offer');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Offer created successfully:', data);
|
||||
toast.success('Ad created successfully!');
|
||||
onAdCreated();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Create ad error:', error);
|
||||
toast.error('Failed to create offer');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Create P2P Offer</CardTitle>
|
||||
<CardDescription>
|
||||
Lock your crypto in escrow and set your price
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Ad Type Selection */}
|
||||
<div>
|
||||
<Label>I want to</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={adType === 'sell' ? 'default' : 'outline'}
|
||||
className={adType === 'sell' ? 'bg-red-600 hover:bg-red-700' : ''}
|
||||
onClick={() => setAdType('sell')}
|
||||
>
|
||||
Sell {token}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={adType === 'buy' ? 'default' : 'outline'}
|
||||
className={adType === 'buy' ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
onClick={() => setAdType('buy')}
|
||||
>
|
||||
Buy {token}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{adType === 'sell'
|
||||
? 'You will receive fiat payment and send crypto to buyer'
|
||||
: 'You will send fiat payment and receive crypto from seller'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crypto Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="token">Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HEZ">HEZ</SelectItem>
|
||||
<SelectItem value="PEZ">PEZ</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="amountCrypto">Amount ({token})</Label>
|
||||
<Input
|
||||
id="amountCrypto"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amountCrypto}
|
||||
onChange={e => setAmountCrypto(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiat Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="fiatCurrency">Fiat Currency</Label>
|
||||
<Select value={fiatCurrency} onValueChange={(v) => setFiatCurrency(v as FiatCurrency)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Primary regions - Kurdistan & neighboring */}
|
||||
<SelectItem value="TRY">🇹🇷 Turkish Lira (TRY) - Bakur</SelectItem>
|
||||
<SelectItem value="IQD">🇮🇶 Iraqi Dinar (IQD) - Başûr</SelectItem>
|
||||
<SelectItem value="IRR">🇮🇷 Iranian Rial (IRR) - Rojhilat</SelectItem>
|
||||
{/* Eurozone diaspora */}
|
||||
<SelectItem value="EUR">🇪🇺 Euro (EUR) - EU</SelectItem>
|
||||
{/* Other diaspora regions */}
|
||||
<SelectItem value="USD">🇺🇸 US Dollar (USD)</SelectItem>
|
||||
<SelectItem value="GBP">🇬🇧 British Pound (GBP)</SelectItem>
|
||||
<SelectItem value="SEK">🇸🇪 Swedish Krona (SEK)</SelectItem>
|
||||
<SelectItem value="CHF">🇨🇭 Swiss Franc (CHF)</SelectItem>
|
||||
<SelectItem value="NOK">🇳🇴 Norwegian Krone (NOK)</SelectItem>
|
||||
<SelectItem value="DKK">🇩🇰 Danish Krone (DKK)</SelectItem>
|
||||
<SelectItem value="AUD">🇦🇺 Australian Dollar (AUD)</SelectItem>
|
||||
<SelectItem value="CAD">🇨🇦 Canadian Dollar (CAD)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fiatAmount">Total Amount ({fiatCurrency})</Label>
|
||||
<Input
|
||||
id="fiatAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={fiatAmount}
|
||||
onChange={e => setFiatAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Display */}
|
||||
{amountCrypto && fiatAmount && (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<p className="text-sm text-gray-400">Price per {token}</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{pricePerUnit} {fiatCurrency}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
<div>
|
||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||
<Select onValueChange={handlePaymentMethodChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select payment method..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentMethods.map(method => (
|
||||
<SelectItem key={method.id} value={method.id}>
|
||||
{method.method_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Payment Details Fields */}
|
||||
{selectedPaymentMethod && Object.keys(selectedPaymentMethod.fields).length > 0 && (
|
||||
<div className="space-y-4 p-4 border border-gray-700 rounded-lg">
|
||||
<h3 className="font-semibold text-white">Payment Details</h3>
|
||||
{Object.entries(selectedPaymentMethod.fields).map(([field, placeholder]) => (
|
||||
<div key={field}>
|
||||
<Label htmlFor={field}>
|
||||
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Label>
|
||||
<Input
|
||||
id={field}
|
||||
value={paymentDetails[field] || ''}
|
||||
onChange={(e) => handlePaymentDetailChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Limits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="minOrder">Min Order (optional)</Label>
|
||||
<Input
|
||||
id="minOrder"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={minOrderAmount}
|
||||
onChange={e => setMinOrderAmount(e.target.value)}
|
||||
placeholder="Minimum amount (optional)"
|
||||
className="placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxOrder">Max Order (optional)</Label>
|
||||
<Input
|
||||
id="maxOrder"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={maxOrderAmount}
|
||||
onChange={e => setMaxOrderAmount(e.target.value)}
|
||||
placeholder="Maximum amount (optional)"
|
||||
className="placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Limit */}
|
||||
<div>
|
||||
<Label htmlFor="timeLimit">Payment Time Limit (minutes)</Label>
|
||||
<Select value={timeLimit.toString()} onValueChange={(v) => setTimeLimit(parseInt(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
<SelectItem value="120">2 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateAd}
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating offer & locking escrow...
|
||||
</>
|
||||
) : (
|
||||
'Create Offer'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Loader2,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
QrCode,
|
||||
Wallet
|
||||
} from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getPlatformWalletAddress,
|
||||
verifyDeposit,
|
||||
type CryptoToken
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface DepositModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type DepositStep = 'select' | 'send' | 'verify' | 'success';
|
||||
|
||||
export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const { balances, signTransaction } = useWallet();
|
||||
|
||||
const [step, setStep] = useState<DepositStep>('select');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [platformWallet, setPlatformWallet] = useState<string>('');
|
||||
const [txHash, setTxHash] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
// Fetch platform wallet address on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchPlatformWallet();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchPlatformWallet = async () => {
|
||||
const address = await getPlatformWalletAddress();
|
||||
setPlatformWallet(address);
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setStep('select');
|
||||
setToken('HEZ');
|
||||
setAmount('');
|
||||
setTxHash('');
|
||||
setLoading(false);
|
||||
setCopied(false);
|
||||
setVerifying(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetModal();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCopyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(platformWallet);
|
||||
setCopied(true);
|
||||
toast.success('Address copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy address');
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableBalance = () => {
|
||||
if (token === 'HEZ') return balances.HEZ;
|
||||
if (token === 'PEZ') return balances.PEZ;
|
||||
return '0';
|
||||
};
|
||||
|
||||
const handleSendDeposit = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast.error('Please connect your wallet');
|
||||
return;
|
||||
}
|
||||
|
||||
const depositAmount = parseFloat(amount);
|
||||
if (isNaN(depositAmount) || depositAmount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Build the transfer transaction
|
||||
const DECIMALS = 12;
|
||||
const amountBN = BigInt(Math.floor(depositAmount * 10 ** DECIMALS));
|
||||
|
||||
let tx;
|
||||
if (token === 'HEZ') {
|
||||
// Native transfer
|
||||
tx = api.tx.balances.transferKeepAlive(platformWallet, amountBN);
|
||||
} else {
|
||||
// Asset transfer (PEZ = asset ID 1)
|
||||
const assetId = token === 'PEZ' ? 1 : 0;
|
||||
tx = api.tx.assets.transfer(assetId, platformWallet, amountBN);
|
||||
}
|
||||
|
||||
toast.info('Please sign the transaction in your wallet...');
|
||||
|
||||
// Sign and send
|
||||
const hash = await signTransaction(tx);
|
||||
|
||||
if (hash) {
|
||||
setTxHash(hash);
|
||||
setStep('verify');
|
||||
toast.success('Transaction sent! Please verify your deposit.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Deposit transaction error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Transaction failed';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyDeposit = async () => {
|
||||
if (!txHash) {
|
||||
toast.error('Please enter the transaction hash');
|
||||
return;
|
||||
}
|
||||
|
||||
const depositAmount = parseFloat(amount);
|
||||
if (isNaN(depositAmount) || depositAmount <= 0) {
|
||||
toast.error('Invalid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
|
||||
try {
|
||||
const success = await verifyDeposit(txHash, token, depositAmount);
|
||||
|
||||
if (success) {
|
||||
setStep('success');
|
||||
onSuccess?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verify deposit error:', error);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HEZ">HEZ (Native)</SelectItem>
|
||||
<SelectItem value="PEZ">PEZ</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Amount to Deposit</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min="0"
|
||||
step="0.0001"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{token}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wallet Balance: {parseFloat(getAvailableBalance()).toFixed(4)} {token}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Wallet className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You will send {token} from your connected wallet to the P2P platform escrow.
|
||||
After confirmation, the amount will be credited to your P2P internal balance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep('send')}
|
||||
disabled={!amount || parseFloat(amount) <= 0}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'send':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<QrCode className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Send {amount} {token} to:</p>
|
||||
</div>
|
||||
|
||||
{platformWallet ? (
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 rounded-lg bg-background border font-mono text-xs break-all">
|
||||
{platformWallet}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleCopyAddress}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Address
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Only send {token} on the PezkuwiChain network. Sending other tokens or using
|
||||
other networks will result in permanent loss of funds.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setStep('select')}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleSendDeposit}
|
||||
disabled={loading || !platformWallet}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Send {amount} {token}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'verify':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Transaction sent! Please verify your deposit to credit your P2P balance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Transaction Hash</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={txHash}
|
||||
onChange={(e) => setTxHash(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => window.open(`https://explorer.pezkuwichain.io/tx/${txHash}`, '_blank')}
|
||||
disabled={!txHash}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Token</p>
|
||||
<p className="font-semibold">{token}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount</p>
|
||||
<p className="font-semibold">{amount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyDeposit}
|
||||
disabled={verifying || !txHash}
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Deposit'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-green-500">
|
||||
Deposit Successful!
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{amount} {token} has been added to your P2P internal balance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can now create sell offers or trade P2P using your internal balance.
|
||||
No blockchain fees during P2P trades!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Deposit to P2P Balance
|
||||
</DialogTitle>
|
||||
{step !== 'success' && (
|
||||
<DialogDescription>
|
||||
{step === 'select' && 'Deposit crypto from your wallet to P2P internal balance'}
|
||||
{step === 'send' && 'Send tokens to the platform escrow wallet'}
|
||||
{step === 'verify' && 'Verify your transaction to credit your balance'}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{renderStepContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AlertTriangle, Upload, X, FileText } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface DisputeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isBuyer: boolean;
|
||||
}
|
||||
|
||||
interface EvidenceFile {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string;
|
||||
type: 'image' | 'document';
|
||||
}
|
||||
|
||||
const DISPUTE_REASONS = [
|
||||
{ value: 'payment_not_received', label: 'Payment not received' },
|
||||
{ value: 'wrong_amount', label: 'Wrong amount received' },
|
||||
{ value: 'seller_not_responding', label: 'Seller not responding' },
|
||||
{ value: 'buyer_not_responding', label: 'Buyer not responding' },
|
||||
{ value: 'fraudulent_behavior', label: 'Fraudulent behavior' },
|
||||
{ value: 'fake_payment_proof', label: 'Fake payment proof' },
|
||||
{ value: 'account_mismatch', label: 'Payment account name mismatch' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export function DisputeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
counterpartyWallet,
|
||||
isBuyer,
|
||||
}: DisputeModalProps) {
|
||||
useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [evidenceFiles, setEvidenceFiles] = useState<EvidenceFile[]>([]);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Filter reasons based on role
|
||||
const availableReasons = DISPUTE_REASONS.filter((r) => {
|
||||
if (isBuyer) {
|
||||
return r.value !== 'buyer_not_responding' && r.value !== 'payment_not_received';
|
||||
} else {
|
||||
return r.value !== 'seller_not_responding' && r.value !== 'fake_payment_proof';
|
||||
}
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const newFiles: EvidenceFile[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
if (evidenceFiles.length + newFiles.length >= 5) {
|
||||
toast.error('Maximum 5 evidence files allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`File ${file.name} is too large (max 10MB)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const evidence: EvidenceFile = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
type: isImage ? 'image' : 'document',
|
||||
};
|
||||
|
||||
if (isImage) {
|
||||
evidence.preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
newFiles.push(evidence);
|
||||
});
|
||||
|
||||
setEvidenceFiles((prev) => [...prev, ...newFiles]);
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeEvidence = (id: string) => {
|
||||
setEvidenceFiles((prev) => {
|
||||
const file = prev.find((f) => f.id === id);
|
||||
if (file?.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const uploadEvidence = async (disputeId: string): Promise<string[]> => {
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
for (const evidence of evidenceFiles) {
|
||||
const fileName = `disputes/${disputeId}/${evidence.id}-${evidence.file.name}`;
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('p2p-evidence')
|
||||
.upload(fileName, evidence.file);
|
||||
|
||||
if (error) {
|
||||
console.error('Evidence upload failed:', error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('p2p-evidence')
|
||||
.getPublicUrl(data.path);
|
||||
|
||||
uploadedUrls.push(urlData.publicUrl);
|
||||
}
|
||||
|
||||
return uploadedUrls;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reason) {
|
||||
toast.error('Please select a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description || description.length < 20) {
|
||||
toast.error('Please provide a detailed description (at least 20 characters)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!termsAccepted) {
|
||||
toast.error('Please accept the terms and conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Create dispute
|
||||
const { data: dispute, error: disputeError } = await supabase
|
||||
.from('p2p_disputes')
|
||||
.insert({
|
||||
trade_id: tradeId,
|
||||
opened_by: user.id,
|
||||
reason,
|
||||
description,
|
||||
status: 'open',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (disputeError) throw disputeError;
|
||||
|
||||
// Upload evidence files
|
||||
if (evidenceFiles.length > 0) {
|
||||
const evidenceUrls = await uploadEvidence(dispute.id);
|
||||
|
||||
// Insert evidence records
|
||||
const evidenceRecords = evidenceUrls.map((url, index) => ({
|
||||
dispute_id: dispute.id,
|
||||
uploaded_by: user.id,
|
||||
evidence_type: evidenceFiles[index].type === 'image' ? 'screenshot' : 'document',
|
||||
file_url: url,
|
||||
description: `Evidence ${index + 1}`,
|
||||
}));
|
||||
|
||||
await supabase.from('p2p_dispute_evidence').insert(evidenceRecords);
|
||||
}
|
||||
|
||||
// Update trade status to disputed
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({ status: 'disputed' })
|
||||
.eq('id', tradeId);
|
||||
|
||||
// Create notification for counterparty
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'dispute_opened',
|
||||
title: 'Dispute Opened',
|
||||
message: `A dispute has been opened for your trade. Reason: ${reason}`,
|
||||
reference_type: 'dispute',
|
||||
reference_id: dispute.id,
|
||||
});
|
||||
|
||||
// Fetch admin user IDs and create notifications for each admin
|
||||
const { data: adminIds, error: adminError } = await supabase.rpc('get_admin_user_ids');
|
||||
if (adminError) {
|
||||
console.error('Failed to fetch admin IDs:', adminError);
|
||||
// Continue without admin notifications if fetching fails, but log the error
|
||||
} else if (adminIds && adminIds.length > 0) {
|
||||
const adminNotifications = adminIds.map((admin: { user_id: string }) => ({
|
||||
user_id: admin.user_id,
|
||||
type: 'dispute_opened',
|
||||
title: 'New Dispute Requires Attention',
|
||||
message: `Dispute #${dispute.id.slice(0, 8)} opened. Trade: ${tradeId.slice(0, 8)}`,
|
||||
reference_type: 'dispute',
|
||||
reference_id: dispute.id,
|
||||
}));
|
||||
await supabase.from('p2p_notifications').insert(adminNotifications);
|
||||
}
|
||||
|
||||
toast.success('Dispute opened successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to open dispute:', error);
|
||||
toast.error('Failed to open dispute. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Cleanup previews
|
||||
evidenceFiles.forEach((f) => {
|
||||
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||
});
|
||||
setReason('');
|
||||
setDescription('');
|
||||
setEvidenceFiles([]);
|
||||
setTermsAccepted(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Open Dispute
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please provide details about the issue. Our support team will review your case
|
||||
and contact both parties for resolution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Reason Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Reason for Dispute *</Label>
|
||||
<Select value={reason} onValueChange={setReason}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a reason..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableReasons.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
Detailed Description * <span className="text-muted-foreground text-xs">(min 20 chars)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Please describe the issue in detail. Include relevant transaction IDs, timestamps, and any communication with the counterparty..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{description.length}/2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Evidence Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>Evidence (Optional - max 5 files, 10MB each)</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,.pdf,.doc,.docx"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={evidenceFiles.length >= 5}
|
||||
>
|
||||
Upload Evidence
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Screenshots, bank statements, chat logs, receipts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence Preview */}
|
||||
{evidenceFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{evidenceFiles.map((evidence) => (
|
||||
<div
|
||||
key={evidence.id}
|
||||
className="relative border rounded-lg p-2 flex items-center gap-2"
|
||||
>
|
||||
{evidence.type === 'image' ? (
|
||||
<img
|
||||
src={evidence.preview}
|
||||
alt="Evidence"
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<FileText className="w-10 h-10 text-blue-500" />
|
||||
)}
|
||||
<span className="text-xs truncate flex-1">
|
||||
{evidence.file.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeEvidence(evidence.id)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
Important Notice
|
||||
</p>
|
||||
<ul className="text-amber-700 dark:text-amber-300 text-xs mt-1 space-y-1">
|
||||
<li>• False disputes may result in account restrictions</li>
|
||||
<li>• Resolution typically takes 1-3 business days</li>
|
||||
<li>• Both parties can submit evidence</li>
|
||||
<li>• Admin decision is final</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms Checkbox */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={termsAccepted}
|
||||
onCheckedChange={(checked) => setTermsAccepted(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="terms" className="text-sm leading-tight cursor-pointer">
|
||||
I confirm that the information provided is accurate and understand that
|
||||
false claims may result in penalties.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !reason || !description || !termsAccepted}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Open Dispute'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Express Mode Component - OKX-Style Quick Trading
|
||||
*
|
||||
* Express mode allows users to quickly buy/sell crypto at the best available rate
|
||||
* without manually selecting an offer. The system automatically matches with
|
||||
* the best available merchant.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Zap, ArrowRight, Shield, Clock, Star, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat';
|
||||
|
||||
interface BestOffer {
|
||||
id: string;
|
||||
seller_id: string;
|
||||
price_per_unit: number;
|
||||
remaining_amount: number;
|
||||
payment_method_name: string;
|
||||
seller_reputation: number;
|
||||
seller_completed_trades: number;
|
||||
time_limit_minutes: number;
|
||||
}
|
||||
|
||||
interface ExpressModeProps {
|
||||
onTradeStarted?: (tradeId: string) => void;
|
||||
}
|
||||
|
||||
const SUPPORTED_TOKENS: CryptoToken[] = ['HEZ', 'PEZ'];
|
||||
|
||||
// All supported fiat currencies including Kurdish Diaspora countries
|
||||
const SUPPORTED_FIATS: { code: FiatCurrency; name: string; symbol: string; region: string }[] = [
|
||||
// Primary regions (Kurdistan & neighboring)
|
||||
{ code: 'TRY', name: 'Turkish Lira', symbol: '₺', region: 'Bakur' },
|
||||
{ code: 'IQD', name: 'Iraqi Dinar', symbol: 'د.ع', region: 'Başûr' },
|
||||
{ code: 'IRR', name: 'Iranian Rial', symbol: '﷼', region: 'Rojhilat' },
|
||||
// Eurozone diaspora
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€', region: 'EU' },
|
||||
// Other diaspora regions
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$', region: 'USA' },
|
||||
{ code: 'GBP', name: 'British Pound', symbol: '£', region: 'UK' },
|
||||
{ code: 'SEK', name: 'Swedish Krona', symbol: 'kr', region: 'Sweden' },
|
||||
{ code: 'CHF', name: 'Swiss Franc', symbol: 'Fr.', region: 'Switzerland' },
|
||||
{ code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', region: 'Norway' },
|
||||
{ code: 'DKK', name: 'Danish Krone', symbol: 'kr', region: 'Denmark' },
|
||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$', region: 'Australia' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', region: 'Canada' },
|
||||
];
|
||||
|
||||
export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
||||
const [mode, setMode] = useState<'buy' | 'sell'>('buy');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
const [fiat, setFiat] = useState<FiatCurrency>('TRY');
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [inputType, setInputType] = useState<'crypto' | 'fiat'>('fiat');
|
||||
const [bestOffer, setBestOffer] = useState<BestOffer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Calculate conversion
|
||||
const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || '';
|
||||
const cryptoAmount = inputType === 'crypto'
|
||||
? parseFloat(amount) || 0
|
||||
: bestOffer ? (parseFloat(amount) || 0) / bestOffer.price_per_unit : 0;
|
||||
const fiatAmount = inputType === 'fiat'
|
||||
? parseFloat(amount) || 0
|
||||
: bestOffer ? (parseFloat(amount) || 0) * bestOffer.price_per_unit : 0;
|
||||
|
||||
// Fetch best offer when parameters change
|
||||
useEffect(() => {
|
||||
const fetchBestOffer = async () => {
|
||||
if (!amount || parseFloat(amount) <= 0) {
|
||||
setBestOffer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Get best offer based on mode
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select(`
|
||||
id,
|
||||
seller_id,
|
||||
price_per_unit,
|
||||
remaining_amount,
|
||||
time_limit_minutes,
|
||||
payment_methods!inner(method_name),
|
||||
profiles!p2p_fiat_offers_seller_id_fkey(display_name),
|
||||
p2p_reputation!p2p_fiat_offers_seller_id_fkey(reputation_score, completed_trades)
|
||||
`)
|
||||
.eq('token', token)
|
||||
.eq('fiat_currency', fiat)
|
||||
.eq('status', 'open')
|
||||
.gt('remaining_amount', 0)
|
||||
.order('price_per_unit', { ascending: mode === 'buy' })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
setBestOffer(null);
|
||||
} else if (data) {
|
||||
setBestOffer({
|
||||
id: data.id,
|
||||
seller_id: data.seller_id,
|
||||
price_per_unit: data.price_per_unit,
|
||||
remaining_amount: data.remaining_amount,
|
||||
payment_method_name: (data.payment_methods as { method_name?: string })?.method_name || 'Bank Transfer',
|
||||
seller_reputation: (data.p2p_reputation as { reputation_score?: number })?.reputation_score || 0,
|
||||
seller_completed_trades: (data.p2p_reputation as { completed_trades?: number })?.completed_trades || 0,
|
||||
time_limit_minutes: data.time_limit_minutes,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch best offer error:', err);
|
||||
setBestOffer(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(fetchBestOffer, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [amount, token, fiat, mode]);
|
||||
|
||||
// Handle express trade
|
||||
const handleExpressTrade = async () => {
|
||||
if (!user) {
|
||||
toast.error('Please login to trade');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bestOffer) {
|
||||
toast.error('No offers available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cryptoAmount > bestOffer.remaining_amount) {
|
||||
toast.error(`Maximum available: ${bestOffer.remaining_amount} ${token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Accept the best offer
|
||||
const { data: result, error } = await supabase.rpc('accept_p2p_offer', {
|
||||
p_offer_id: bestOffer.id,
|
||||
p_buyer_id: user.id,
|
||||
p_buyer_wallet: '', // Will be set from user profile
|
||||
p_amount: cryptoAmount
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const response = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to start trade');
|
||||
}
|
||||
|
||||
toast.success('Express trade started!');
|
||||
|
||||
if (onTradeStarted) {
|
||||
onTradeStarted(response.trade_id);
|
||||
} else {
|
||||
navigate(`/p2p/trade/${response.trade_id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Express trade error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start trade');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-yellow-500/10 to-orange-500/10 border-yellow-500/30">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Zap className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg text-white">Express Mode</CardTitle>
|
||||
<p className="text-xs text-gray-400">Instant best-rate matching</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
|
||||
Fast
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Buy/Sell Toggle */}
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'buy' | 'sell')}>
|
||||
<TabsList className="grid w-full grid-cols-2 bg-gray-800">
|
||||
<TabsTrigger value="buy" className="data-[state=active]:bg-green-600">
|
||||
Buy {token}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sell" className="data-[state=active]:bg-red-600">
|
||||
Sell {token}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Token & Fiat Selection */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Crypto</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_TOKENS.map(t => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400 text-xs">Currency</Label>
|
||||
<Select value={fiat} onValueChange={(v) => setFiat(v as FiatCurrency)}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_FIATS.map(f => (
|
||||
<SelectItem key={f.code} value={f.code}>
|
||||
{f.symbol} {f.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label className="text-gray-400 text-xs">
|
||||
{inputType === 'fiat' ? `Amount (${fiat})` : `Amount (${token})`}
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-yellow-400 h-auto p-0"
|
||||
onClick={() => setInputType(inputType === 'fiat' ? 'crypto' : 'fiat')}
|
||||
>
|
||||
Switch to {inputType === 'fiat' ? token : fiat}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-lg pr-16"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{inputType === 'fiat' ? fiatSymbol : token}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion Display */}
|
||||
{bestOffer && parseFloat(amount) > 0 && (
|
||||
<div className="p-3 bg-gray-800/50 rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">You {mode === 'buy' ? 'pay' : 'receive'}</span>
|
||||
<span className="text-white font-medium">
|
||||
{fiatSymbol}{fiatAmount.toLocaleString(undefined, { maximumFractionDigits: 2 })} {fiat}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">You {mode === 'buy' ? 'receive' : 'send'}</span>
|
||||
<span className="text-white font-medium">
|
||||
{cryptoAmount.toLocaleString(undefined, { maximumFractionDigits: 6 })} {token}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-700 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Rate</span>
|
||||
<span className="text-gray-300">
|
||||
1 {token} = {fiatSymbol}{bestOffer.price_per_unit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Merchant Rating</span>
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{bestOffer.seller_reputation}% ({bestOffer.seller_completed_trades} trades)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Payment</span>
|
||||
<span className="text-gray-300">{bestOffer.payment_method_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Time Limit</span>
|
||||
<span className="text-gray-300 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{bestOffer.time_limit_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Offers Warning */}
|
||||
{!bestOffer && parseFloat(amount) > 0 && !isLoading && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">
|
||||
No offers available for this pair
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Express Trade Button */}
|
||||
<Button
|
||||
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-semibold"
|
||||
size="lg"
|
||||
onClick={handleExpressTrade}
|
||||
disabled={!bestOffer || isLoading || isProcessing || !user}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>Processing...</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{mode === 'buy' ? 'Buy' : 'Sell'} {token} Instantly
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="w-3 h-3 text-green-400" />
|
||||
Escrow Protected
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-blue-400" />
|
||||
Verified Merchants
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Wallet,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
Unlock
|
||||
} from 'lucide-react';
|
||||
import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface InternalBalanceCardProps {
|
||||
onDeposit?: () => void;
|
||||
onWithdraw?: () => void;
|
||||
}
|
||||
|
||||
export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCardProps) {
|
||||
const [balances, setBalances] = useState<InternalBalance[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchBalances = async () => {
|
||||
try {
|
||||
const data = await getInternalBalances();
|
||||
setBalances(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balances:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBalances();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchBalances();
|
||||
};
|
||||
|
||||
const formatBalance = (value: number, decimals: number = 4) => {
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Wallet className="h-5 w-5" />
|
||||
P2P Internal Balance
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Internal balance for P2P trading. Deposit to start selling.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{balances.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Wallet className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No balance yet</p>
|
||||
<p className="text-xs">Deposit crypto to start P2P trading</p>
|
||||
</div>
|
||||
) : (
|
||||
balances.map((balance) => (
|
||||
<div
|
||||
key={balance.token}
|
||||
className="p-4 rounded-lg bg-muted/50 border"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-primary">
|
||||
{balance.token.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold">{balance.token}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Total: {formatBalance(balance.total_balance)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Unlock className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Available</p>
|
||||
<p className="font-medium text-green-600">
|
||||
{formatBalance(balance.available_balance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-yellow-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Locked (Escrow)</p>
|
||||
<p className="font-medium text-yellow-600">
|
||||
{formatBalance(balance.locked_balance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span>Total Deposited: </span>
|
||||
<span className="text-foreground">{formatBalance(balance.total_deposited, 2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Total Withdrawn: </span>
|
||||
<span className="text-foreground">{formatBalance(balance.total_withdrawn, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={onDeposit}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-2" />
|
||||
Deposit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onWithdraw}
|
||||
disabled={balances.every(b => b.available_balance <= 0)}
|
||||
>
|
||||
<ArrowUpFromLine className="h-4 w-4 mr-2" />
|
||||
Withdraw
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { MerchantTierBadge, type MerchantTier } from './MerchantTierBadge';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Crown,
|
||||
Diamond,
|
||||
Info,
|
||||
Loader2,
|
||||
Lock,
|
||||
Shield,
|
||||
Star,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
// Tier requirements interface
|
||||
interface TierRequirements {
|
||||
tier: MerchantTier;
|
||||
min_trades: number;
|
||||
min_completion_rate: number;
|
||||
min_volume_30d: number;
|
||||
deposit_required: number;
|
||||
deposit_token: string;
|
||||
max_pending_orders: number;
|
||||
max_order_amount: number;
|
||||
featured_ads_allowed: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// User stats interface
|
||||
interface UserStats {
|
||||
completed_trades: number;
|
||||
completion_rate: number;
|
||||
volume_30d: number;
|
||||
}
|
||||
|
||||
// Current tier info
|
||||
interface CurrentTierInfo {
|
||||
tier: MerchantTier;
|
||||
application_status: string | null;
|
||||
applied_for_tier: string | null;
|
||||
}
|
||||
|
||||
// Default tier requirements
|
||||
const DEFAULT_REQUIREMENTS: TierRequirements[] = [
|
||||
{
|
||||
tier: 'lite',
|
||||
min_trades: 0,
|
||||
min_completion_rate: 0,
|
||||
min_volume_30d: 0,
|
||||
deposit_required: 0,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 5,
|
||||
max_order_amount: 10000,
|
||||
featured_ads_allowed: 0,
|
||||
description: 'Basic tier for all verified users'
|
||||
},
|
||||
{
|
||||
tier: 'super',
|
||||
min_trades: 20,
|
||||
min_completion_rate: 90,
|
||||
min_volume_30d: 5000,
|
||||
deposit_required: 10000,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 20,
|
||||
max_order_amount: 100000,
|
||||
featured_ads_allowed: 3,
|
||||
description: 'Professional trader tier with higher limits'
|
||||
},
|
||||
{
|
||||
tier: 'diamond',
|
||||
min_trades: 100,
|
||||
min_completion_rate: 95,
|
||||
min_volume_30d: 25000,
|
||||
deposit_required: 50000,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 50,
|
||||
max_order_amount: 150000,
|
||||
featured_ads_allowed: 10,
|
||||
description: 'Elite merchant tier with maximum privileges'
|
||||
}
|
||||
];
|
||||
|
||||
// Tier icon mapping
|
||||
const TIER_ICONS = {
|
||||
lite: Shield,
|
||||
super: Star,
|
||||
diamond: Diamond
|
||||
};
|
||||
|
||||
// Tier colors
|
||||
const TIER_COLORS = {
|
||||
lite: 'text-gray-400',
|
||||
super: 'text-yellow-500',
|
||||
diamond: 'text-purple-500'
|
||||
};
|
||||
|
||||
export function MerchantApplication() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requirements, setRequirements] = useState<TierRequirements[]>(DEFAULT_REQUIREMENTS);
|
||||
const [userStats, setUserStats] = useState<UserStats>({ completed_trades: 0, completion_rate: 0, volume_30d: 0 });
|
||||
const [currentTier, setCurrentTier] = useState<CurrentTierInfo>({ tier: 'lite', application_status: null, applied_for_tier: null });
|
||||
const [applyModalOpen, setApplyModalOpen] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<MerchantTier | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
// Fetch tier requirements
|
||||
const { data: reqData } = await supabase
|
||||
.from('p2p_tier_requirements')
|
||||
.select('*')
|
||||
.order('min_trades', { ascending: true });
|
||||
|
||||
if (reqData && reqData.length > 0) {
|
||||
setRequirements(reqData as TierRequirements[]);
|
||||
}
|
||||
|
||||
// Fetch user reputation
|
||||
const { data: repData } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('completed_trades')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
// Fetch merchant stats
|
||||
const { data: statsData } = await supabase
|
||||
.from('p2p_merchant_stats')
|
||||
.select('completion_rate_30d, total_volume_30d')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
// Fetch current tier
|
||||
const { data: tierData } = await supabase
|
||||
.from('p2p_merchant_tiers')
|
||||
.select('tier, application_status, applied_for_tier')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
setUserStats({
|
||||
completed_trades: repData?.completed_trades || 0,
|
||||
completion_rate: statsData?.completion_rate_30d || 0,
|
||||
volume_30d: statsData?.total_volume_30d || 0
|
||||
});
|
||||
|
||||
if (tierData) {
|
||||
setCurrentTier(tierData as CurrentTierInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching merchant data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate progress for a requirement
|
||||
const calculateProgress = (current: number, required: number): number => {
|
||||
if (required === 0) return 100;
|
||||
return Math.min((current / required) * 100, 100);
|
||||
};
|
||||
|
||||
// Check if tier is unlocked
|
||||
const isTierUnlocked = (tier: TierRequirements): boolean => {
|
||||
return (
|
||||
userStats.completed_trades >= tier.min_trades &&
|
||||
userStats.completion_rate >= tier.min_completion_rate &&
|
||||
userStats.volume_30d >= tier.min_volume_30d
|
||||
);
|
||||
};
|
||||
|
||||
// Get tier index for comparison
|
||||
const getTierIndex = (tier: MerchantTier): number => {
|
||||
return requirements.findIndex(r => r.tier === tier);
|
||||
};
|
||||
|
||||
// Check if can apply for tier
|
||||
const canApplyForTier = (tier: TierRequirements): boolean => {
|
||||
if (!isTierUnlocked(tier)) return false;
|
||||
if (getTierIndex(currentTier.tier) >= getTierIndex(tier.tier)) return false;
|
||||
if (currentTier.application_status === 'pending') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Apply for tier
|
||||
const applyForTier = async () => {
|
||||
if (!selectedTier) return;
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { data, error } = await supabase.rpc('apply_for_tier_upgrade', {
|
||||
p_user_id: user.id,
|
||||
p_target_tier: selectedTier
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data && data[0]) {
|
||||
if (data[0].success) {
|
||||
toast.success('Application submitted successfully!');
|
||||
setApplyModalOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(data[0].message || 'Application failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying for tier:', error);
|
||||
toast.error('Failed to submit application');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open apply modal
|
||||
const openApplyModal = (tier: MerchantTier) => {
|
||||
setSelectedTier(tier);
|
||||
setApplyModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Tier Card */}
|
||||
<Card className="bg-gradient-to-br from-kurdish-green/10 to-transparent border-kurdish-green/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-kurdish-green" />
|
||||
Your Merchant Status
|
||||
</CardTitle>
|
||||
<CardDescription>Current tier and application status</CardDescription>
|
||||
</div>
|
||||
<MerchantTierBadge tier={currentTier.tier} size="lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{userStats.completed_trades}</p>
|
||||
<p className="text-sm text-muted-foreground">Completed Trades</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{userStats.completion_rate.toFixed(1)}%</p>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">${userStats.volume_30d.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">30-Day Volume</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTier.application_status === 'pending' && (
|
||||
<Alert className="mt-4 bg-yellow-500/10 border-yellow-500/30">
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-500">
|
||||
Your application for {currentTier.applied_for_tier?.toUpperCase()} tier is pending review.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tier Progression */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{requirements.map((tier) => {
|
||||
const TierIcon = TIER_ICONS[tier.tier];
|
||||
const isCurrentTier = tier.tier === currentTier.tier;
|
||||
const isUnlocked = isTierUnlocked(tier);
|
||||
const canApply = canApplyForTier(tier);
|
||||
const isPastTier = getTierIndex(tier.tier) < getTierIndex(currentTier.tier);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={tier.tier}
|
||||
className={`relative overflow-hidden transition-all ${
|
||||
isCurrentTier
|
||||
? 'border-kurdish-green bg-kurdish-green/5'
|
||||
: isPastTier
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: isUnlocked
|
||||
? 'border-yellow-500/30 hover:border-yellow-500/50'
|
||||
: 'opacity-75'
|
||||
}`}
|
||||
>
|
||||
{/* Current tier indicator */}
|
||||
{isCurrentTier && (
|
||||
<div className="absolute top-0 right-0 bg-kurdish-green text-white text-xs px-2 py-0.5 rounded-bl">
|
||||
Current
|
||||
</div>
|
||||
)}
|
||||
{isPastTier && (
|
||||
<div className="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-0.5 rounded-bl">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-full bg-background ${TIER_COLORS[tier.tier]}`}>
|
||||
<TierIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg capitalize">{tier.tier}</CardTitle>
|
||||
<CardDescription className="text-xs">{tier.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Requirements */}
|
||||
<div className="space-y-3">
|
||||
{/* Trades */}
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Completed Trades</span>
|
||||
<span>{userStats.completed_trades} / {tier.min_trades}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.completed_trades, tier.min_trades)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Completion Rate */}
|
||||
{tier.min_completion_rate > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Completion Rate</span>
|
||||
<span>{userStats.completion_rate.toFixed(1)}% / {tier.min_completion_rate}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.completion_rate, tier.min_completion_rate)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Volume */}
|
||||
{tier.min_volume_30d > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>30-Day Volume</span>
|
||||
<span>${userStats.volume_30d.toLocaleString()} / ${tier.min_volume_30d.toLocaleString()}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.volume_30d, tier.min_volume_30d)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-2">Benefits:</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>Up to {tier.max_pending_orders} pending orders</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>Max ${tier.max_order_amount.toLocaleString()} per trade</span>
|
||||
</div>
|
||||
{tier.featured_ads_allowed > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>{tier.featured_ads_allowed} featured ads</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deposit requirement */}
|
||||
{tier.deposit_required > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span>Requires {tier.deposit_required.toLocaleString()} {tier.deposit_token} deposit</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{canApply && (
|
||||
<Button
|
||||
className="w-full mt-2 bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
size="sm"
|
||||
onClick={() => openApplyModal(tier.tier)}
|
||||
>
|
||||
Apply for Upgrade
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Apply Modal */}
|
||||
<Dialog open={applyModalOpen} onOpenChange={setApplyModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-kurdish-green" />
|
||||
Apply for {selectedTier?.toUpperCase()} Tier
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submit your application for tier upgrade. Our team will review it shortly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTier && (
|
||||
<div className="space-y-4">
|
||||
{/* Requirements check */}
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<p className="font-medium text-sm">Requirements Met:</p>
|
||||
{requirements.find(r => r.tier === selectedTier) && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Completed trades requirement</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Completion rate requirement</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>30-day volume requirement</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deposit info */}
|
||||
{(requirements.find(r => r.tier === selectedTier)?.deposit_required ?? 0) > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This tier requires a deposit of{' '}
|
||||
<strong>
|
||||
{requirements.find(r => r.tier === selectedTier)?.deposit_required.toLocaleString()}{' '}
|
||||
{requirements.find(r => r.tier === selectedTier)?.deposit_token}
|
||||
</strong>
|
||||
. You will be prompted to complete the deposit after approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setApplyModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={applyForTier}
|
||||
disabled={applying}
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Submit Application
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchantApplication;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Diamond, Star, Shield } from 'lucide-react';
|
||||
|
||||
export type MerchantTier = 'lite' | 'super' | 'diamond';
|
||||
|
||||
interface MerchantTierBadgeProps {
|
||||
tier: MerchantTier;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const TIER_CONFIG = {
|
||||
lite: {
|
||||
label: 'Lite',
|
||||
icon: Shield,
|
||||
className: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
iconClassName: 'text-gray-400',
|
||||
description: 'Basic verified trader'
|
||||
},
|
||||
super: {
|
||||
label: 'Super',
|
||||
icon: Star,
|
||||
className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30',
|
||||
iconClassName: 'text-yellow-500',
|
||||
description: 'Professional trader with 20+ trades and 90%+ completion rate'
|
||||
},
|
||||
diamond: {
|
||||
label: 'Diamond',
|
||||
icon: Diamond,
|
||||
className: 'bg-purple-500/20 text-purple-500 border-purple-500/30',
|
||||
iconClassName: 'text-purple-500',
|
||||
description: 'Elite merchant with 100+ trades and 95%+ completion rate'
|
||||
}
|
||||
};
|
||||
|
||||
const SIZE_CONFIG = {
|
||||
sm: {
|
||||
badge: 'text-[10px] px-1.5 py-0.5',
|
||||
icon: 'h-3 w-3'
|
||||
},
|
||||
md: {
|
||||
badge: 'text-xs px-2 py-1',
|
||||
icon: 'h-3.5 w-3.5'
|
||||
},
|
||||
lg: {
|
||||
badge: 'text-sm px-3 py-1.5',
|
||||
icon: 'h-4 w-4'
|
||||
}
|
||||
};
|
||||
|
||||
export function MerchantTierBadge({
|
||||
tier,
|
||||
size = 'md',
|
||||
showLabel = true
|
||||
}: MerchantTierBadgeProps) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${sizeConfig.badge} gap-1 cursor-help`}
|
||||
>
|
||||
<Icon className={`${sizeConfig.icon} ${config.iconClassName}`} />
|
||||
{showLabel && <span>{config.label}</span>}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{config.label} Merchant</p>
|
||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone tier icon for compact displays
|
||||
export function MerchantTierIcon({
|
||||
tier,
|
||||
size = 'md'
|
||||
}: {
|
||||
tier: MerchantTier;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help">
|
||||
<Icon className={`${sizeConfig.icon} ${config.iconClassName}`} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{config.label} Merchant</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchantTierBadge;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
DollarSign,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Loader2,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch notifications
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setNotifications(data || []);
|
||||
setUnreadCount(data?.filter(n => !n.is_read).length || 0);
|
||||
} catch (error) {
|
||||
console.error('Fetch notifications error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`notifications-${user.id}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_notifications',
|
||||
filter: `user_id=eq.${user.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
const newNotif = payload.new as Notification;
|
||||
setNotifications(prev => [newNotif, ...prev.slice(0, 19)]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Mark as read
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId);
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notificationId ? { ...n, is_read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error('Mark as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const markAllAsRead = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_read', false);
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch (error) {
|
||||
console.error('Mark all as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification click
|
||||
const handleClick = (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Navigate to reference
|
||||
if (notification.reference_type === 'trade' && notification.reference_id) {
|
||||
navigate(`/p2p/trade/${notification.reference_id}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for notification type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
return <MessageSquare className="w-4 h-4 text-blue-400" />;
|
||||
case 'payment_sent':
|
||||
return <DollarSign className="w-4 h-4 text-yellow-400" />;
|
||||
case 'payment_confirmed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||
case 'trade_cancelled':
|
||||
return <XCircle className="w-4 h-4 text-gray-400" />;
|
||||
case 'dispute_opened':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-400" />;
|
||||
case 'new_rating':
|
||||
return <Star className="w-4 h-4 text-yellow-400" />;
|
||||
case 'new_order':
|
||||
return <DollarSign className="w-4 h-4 text-green-400" />;
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (seconds < 60) return 'Just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-gray-400 hover:text-white"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-red-500 text-white text-xs rounded-full">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 bg-gray-900 border-gray-800"
|
||||
>
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span className="text-white">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-gray-400 hover:text-white h-auto py-1"
|
||||
>
|
||||
<CheckCheck className="w-3 h-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<Bell className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
onClick={() => handleClick(notification)}
|
||||
className={`
|
||||
flex items-start gap-3 p-3 cursor-pointer
|
||||
${!notification.is_read ? 'bg-gray-800/50' : ''}
|
||||
hover:bg-gray-800
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5">{getIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'text-white font-medium' : 'text-gray-300'}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate('/p2p/orders');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="justify-center text-gray-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
View all trades
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
ChevronDown,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Diamond,
|
||||
X,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import types from separate file to avoid react-refresh warning
|
||||
import { type P2PFilters, DEFAULT_FILTERS } from './types';
|
||||
|
||||
// Available fiat currencies
|
||||
const FIAT_CURRENCIES = [
|
||||
{ value: 'TRY', label: 'TRY - Turkish Lira' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'USD', label: 'USD - US Dollar' },
|
||||
{ value: 'IQD', label: 'IQD - Iraqi Dinar' },
|
||||
{ value: 'IRR', label: 'IRR - Iranian Rial' }
|
||||
];
|
||||
|
||||
// Merchant tiers
|
||||
const MERCHANT_TIERS = [
|
||||
{ value: 'super', label: 'Super', icon: Star, color: 'text-yellow-500' },
|
||||
{ value: 'diamond', label: 'Diamond', icon: Diamond, color: 'text-purple-500' }
|
||||
];
|
||||
|
||||
interface OrderFiltersProps {
|
||||
filters: P2PFilters;
|
||||
onFiltersChange: (filters: P2PFilters) => void;
|
||||
variant?: 'inline' | 'sheet';
|
||||
}
|
||||
|
||||
export function OrderFilters({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
variant = 'inline'
|
||||
}: OrderFiltersProps) {
|
||||
const [localFilters, setLocalFilters] = useState<P2PFilters>(filters);
|
||||
const [paymentMethods, setPaymentMethods] = useState<{ id: string; method_name: string }[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
currency: true,
|
||||
payment: false,
|
||||
merchant: false,
|
||||
amount: false
|
||||
});
|
||||
|
||||
// Fetch payment methods
|
||||
useEffect(() => {
|
||||
const fetchPaymentMethods = async () => {
|
||||
const { data } = await supabase
|
||||
.from('payment_methods')
|
||||
.select('id, method_name')
|
||||
.eq('is_active', true);
|
||||
|
||||
if (data) {
|
||||
setPaymentMethods(data);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPaymentMethods();
|
||||
}, []);
|
||||
|
||||
// Update local filters
|
||||
const updateFilter = <K extends keyof P2PFilters>(key: K, value: P2PFilters[K]) => {
|
||||
setLocalFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(localFilters);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = () => {
|
||||
setLocalFilters(DEFAULT_FILTERS);
|
||||
onFiltersChange(DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
// Toggle section
|
||||
const toggleSection = (section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = () => {
|
||||
let count = 0;
|
||||
if (localFilters.token !== 'all') count++;
|
||||
if (localFilters.fiatCurrency !== 'all') count++;
|
||||
if (localFilters.paymentMethods.length > 0) count++;
|
||||
if (localFilters.minAmount !== null || localFilters.maxAmount !== null) count++;
|
||||
if (localFilters.merchantTiers.length > 0) count++;
|
||||
if (localFilters.minCompletionRate > 0) count++;
|
||||
if (localFilters.onlineOnly) count++;
|
||||
if (localFilters.verifiedOnly) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
// Filter content
|
||||
const FilterContent = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Token Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Cryptocurrency</Label>
|
||||
<div className="flex gap-2">
|
||||
{['all', 'HEZ', 'PEZ'].map((token) => (
|
||||
<Button
|
||||
key={token}
|
||||
variant={localFilters.token === token ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('token', token as P2PFilters['token'])}
|
||||
className={localFilters.token === token ? 'bg-kurdish-green hover:bg-kurdish-green-dark' : ''}
|
||||
>
|
||||
{token === 'all' ? 'All' : token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiat Currency */}
|
||||
<Collapsible open={expandedSections.currency} onOpenChange={() => toggleSection('currency')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Fiat Currency</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.currency ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<Select
|
||||
value={localFilters.fiatCurrency}
|
||||
onValueChange={(value) => updateFilter('fiatCurrency', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies</SelectItem>
|
||||
{FIAT_CURRENCIES.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Collapsible open={expandedSections.payment} onOpenChange={() => toggleSection('payment')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Payment Methods</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{localFilters.paymentMethods.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{localFilters.paymentMethods.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.payment ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{paymentMethods.map((method) => (
|
||||
<div key={method.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={method.id}
|
||||
checked={localFilters.paymentMethods.includes(method.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateFilter('paymentMethods', [...localFilters.paymentMethods, method.id]);
|
||||
} else {
|
||||
updateFilter('paymentMethods', localFilters.paymentMethods.filter(id => id !== method.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={method.id} className="text-sm cursor-pointer">
|
||||
{method.method_name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Amount Range */}
|
||||
<Collapsible open={expandedSections.amount} onOpenChange={() => toggleSection('amount')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Amount Range</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.amount ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Min Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={localFilters.minAmount || ''}
|
||||
onChange={(e) => updateFilter('minAmount', e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Max Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={localFilters.maxAmount || ''}
|
||||
onChange={(e) => updateFilter('maxAmount', e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Merchant Tier */}
|
||||
<Collapsible open={expandedSections.merchant} onOpenChange={() => toggleSection('merchant')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Merchant Tier</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.merchant ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{MERCHANT_TIERS.map((tier) => {
|
||||
const Icon = tier.icon;
|
||||
return (
|
||||
<div key={tier.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`tier-${tier.value}`}
|
||||
checked={localFilters.merchantTiers.includes(tier.value as 'super' | 'diamond')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateFilter('merchantTiers', [...localFilters.merchantTiers, tier.value as 'super' | 'diamond']);
|
||||
} else {
|
||||
updateFilter('merchantTiers', localFilters.merchantTiers.filter(t => t !== tier.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`tier-${tier.value}`} className="flex items-center gap-1 text-sm cursor-pointer">
|
||||
<Icon className={`h-4 w-4 ${tier.color}`} />
|
||||
{tier.label}+ only
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Completion Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label>Min Completion Rate: {localFilters.minCompletionRate}%</Label>
|
||||
<Slider
|
||||
value={[localFilters.minCompletionRate]}
|
||||
onValueChange={([value]) => updateFilter('minCompletionRate', value)}
|
||||
max={100}
|
||||
step={5}
|
||||
className="py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle options */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="online-only"
|
||||
checked={localFilters.onlineOnly}
|
||||
onCheckedChange={(checked) => updateFilter('onlineOnly', !!checked)}
|
||||
/>
|
||||
<label htmlFor="online-only" className="text-sm cursor-pointer">
|
||||
Online traders only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="verified-only"
|
||||
checked={localFilters.verifiedOnly}
|
||||
onCheckedChange={(checked) => updateFilter('verifiedOnly', !!checked)}
|
||||
/>
|
||||
<label htmlFor="verified-only" className="text-sm cursor-pointer">
|
||||
Verified merchants only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="space-y-2">
|
||||
<Label>Sort By</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={localFilters.sortBy}
|
||||
onValueChange={(value) => updateFilter('sortBy', value as P2PFilters['sortBy'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="price">Price</SelectItem>
|
||||
<SelectItem value="completion_rate">Completion Rate</SelectItem>
|
||||
<SelectItem value="trades">Trade Count</SelectItem>
|
||||
<SelectItem value="newest">Newest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={localFilters.sortOrder}
|
||||
onValueChange={(value) => updateFilter('sortOrder', value as 'asc' | 'desc')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Low to High</SelectItem>
|
||||
<SelectItem value="desc">High to Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sheet variant (mobile)
|
||||
if (variant === 'sheet') {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFilterCount() > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilterCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Filter Orders
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="py-4 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<FilterContent />
|
||||
</div>
|
||||
<SheetFooter className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t">
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={applyFilters} className="flex-1 bg-kurdish-green hover:bg-kurdish-green-dark">
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline variant (desktop)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<FilterContent />
|
||||
<Button onClick={applyFilters} className="w-full mt-4 bg-kurdish-green hover:bg-kurdish-green-dark">
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick filter bar for top of listing
|
||||
export function QuickFilterBar({
|
||||
filters,
|
||||
onFiltersChange
|
||||
}: {
|
||||
filters: P2PFilters;
|
||||
onFiltersChange: (filters: P2PFilters) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Token quick select */}
|
||||
<div className="flex gap-1">
|
||||
{['all', 'HEZ', 'PEZ'].map((token) => (
|
||||
<Button
|
||||
key={token}
|
||||
variant={filters.token === token ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFiltersChange({ ...filters, token: token as P2PFilters['token'] })}
|
||||
className={filters.token === token ? 'bg-kurdish-green hover:bg-kurdish-green-dark' : ''}
|
||||
>
|
||||
{token === 'all' ? 'All' : token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency select */}
|
||||
<Select
|
||||
value={filters.fiatCurrency}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, fiatCurrency: value })}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-9">
|
||||
<SelectValue placeholder="Currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{FIAT_CURRENCIES.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Amount input */}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="I want to trade..."
|
||||
className="w-[150px] h-9"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : null;
|
||||
onFiltersChange({ ...filters, minAmount: value, maxAmount: value ? value * 1.1 : null });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Advanced filters sheet */}
|
||||
<OrderFilters
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
variant="sheet"
|
||||
/>
|
||||
|
||||
{/* Active filter badges */}
|
||||
{filters.merchantTiers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Star className="h-3 w-3 text-yellow-500" />
|
||||
{filters.merchantTiers.join(', ')}+
|
||||
<button
|
||||
onClick={() => onFiltersChange({ ...filters, merchantTiers: [] })}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{filters.minCompletionRate > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
{filters.minCompletionRate}%+ rate
|
||||
<button
|
||||
onClick={() => onFiltersChange({ ...filters, minCompletionRate: 0 })}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderFilters;
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks } from 'lucide-react';
|
||||
import { AdList } from './AdList';
|
||||
import { CreateAd } from './CreateAd';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import { QuickFilterBar } from './OrderFilters';
|
||||
import { InternalBalanceCard } from './InternalBalanceCard';
|
||||
import { DepositModal } from './DepositModal';
|
||||
import { WithdrawModal } from './WithdrawModal';
|
||||
import { ExpressMode } from './ExpressMode';
|
||||
import { BlockTrade } from './BlockTrade';
|
||||
import { DEFAULT_FILTERS, type P2PFilters } from './types';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface UserStats {
|
||||
activeTrades: number;
|
||||
completedTrades: number;
|
||||
totalVolume: number;
|
||||
}
|
||||
|
||||
export function P2PDashboard() {
|
||||
const [showCreateAd, setShowCreateAd] = useState(false);
|
||||
const [userStats, setUserStats] = useState<UserStats>({ activeTrades: 0, completedTrades: 0, totalVolume: 0 });
|
||||
const [filters, setFilters] = useState<P2PFilters>(DEFAULT_FILTERS);
|
||||
const [showDepositModal, setShowDepositModal] = useState(false);
|
||||
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
||||
const [balanceRefreshKey, setBalanceRefreshKey] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const handleBalanceUpdated = () => {
|
||||
setBalanceRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Fetch user stats
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Count active trades
|
||||
const { count: activeCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.in('status', ['pending', 'payment_sent']);
|
||||
|
||||
// Count completed trades
|
||||
const { count: completedCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.eq('status', 'completed');
|
||||
|
||||
// Calculate total volume
|
||||
const { data: trades } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('fiat_amount')
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.eq('status', 'completed');
|
||||
|
||||
const totalVolume = trades?.reduce((sum, t) => sum + (t.fiat_amount || 0), 0) || 0;
|
||||
|
||||
setUserStats({
|
||||
activeTrades: activeCount || 0,
|
||||
completedTrades: completedCount || 0,
|
||||
totalVolume,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch stats error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/merchant')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
<Store className="w-4 h-4 mr-2" />
|
||||
Merchant
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/orders')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
My Trades
|
||||
{userStats.activeTrades > 0 && (
|
||||
<Badge className="ml-2 bg-yellow-500 text-black">
|
||||
{userStats.activeTrades}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards and Balance Card */}
|
||||
{user && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* Internal Balance Card - Takes more space */}
|
||||
<div className="lg:col-span-1">
|
||||
<InternalBalanceCard
|
||||
key={balanceRefreshKey}
|
||||
onDeposit={() => setShowDepositModal(true)}
|
||||
onWithdraw={() => setShowWithdrawModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.activeTrades}</p>
|
||||
<p className="text-sm text-gray-400">Active Trades</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.completedTrades}</p>
|
||||
<p className="text-sm text-gray-400">Completed</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">${userStats.totalVolume.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-400">Volume</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white">P2P Trading</h1>
|
||||
<p className="text-gray-400">Buy and sell crypto with your local currency.</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateAd(true)}>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Post a New Ad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateAd ? (
|
||||
<CreateAd onAdCreated={() => setShowCreateAd(false)} />
|
||||
) : (
|
||||
<>
|
||||
{/* Quick Filter Bar */}
|
||||
<QuickFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
<Tabs defaultValue="buy">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="express" className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Express
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="buy">Buy</TabsTrigger>
|
||||
<TabsTrigger value="sell">Sell</TabsTrigger>
|
||||
<TabsTrigger value="my-ads">My Ads</TabsTrigger>
|
||||
<TabsTrigger value="otc" className="flex items-center gap-1">
|
||||
<Blocks className="w-3 h-3" />
|
||||
OTC
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="express">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||
<ExpressMode onTradeStarted={(id) => navigate(`/p2p/trade/${id}`)} />
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Why Express Mode?</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Instant best-rate matching
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Verified merchants only
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Escrow protection
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
No manual offer selection
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="buy">
|
||||
<AdList type="buy" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sell">
|
||||
<AdList type="sell" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="my-ads">
|
||||
<AdList type="my-ads" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="otc">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||
<BlockTrade />
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Block Trade Benefits</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Custom pricing negotiation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Dedicated OTC desk support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Multi-tranche settlements
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Enhanced privacy
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Flexible payment terms
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deposit Modal */}
|
||||
<DepositModal
|
||||
isOpen={showDepositModal}
|
||||
onClose={() => setShowDepositModal(false)}
|
||||
onSuccess={handleBalanceUpdated}
|
||||
/>
|
||||
|
||||
{/* Withdraw Modal */}
|
||||
<WithdrawModal
|
||||
isOpen={showWithdrawModal}
|
||||
onClose={() => setShowWithdrawModal(false)}
|
||||
onSuccess={handleBalanceUpdated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Star, Loader2, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface RatingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isBuyer: boolean;
|
||||
}
|
||||
|
||||
export function RatingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
counterpartyWallet,
|
||||
isBuyer,
|
||||
}: RatingModalProps) {
|
||||
const { user } = useAuth();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [review, setReview] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user || rating === 0) {
|
||||
toast.error('Please select a rating');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check if already rated
|
||||
const { data: existingRating } = await supabase
|
||||
.from('p2p_ratings')
|
||||
.select('id')
|
||||
.eq('trade_id', tradeId)
|
||||
.eq('rater_id', user.id)
|
||||
.single();
|
||||
|
||||
if (existingRating) {
|
||||
toast.error('You have already rated this trade');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert rating
|
||||
const { error: ratingError } = await supabase.from('p2p_ratings').insert({
|
||||
trade_id: tradeId,
|
||||
rater_id: user.id,
|
||||
rated_id: counterpartyId,
|
||||
rating,
|
||||
review: review.trim() || null,
|
||||
});
|
||||
|
||||
if (ratingError) throw ratingError;
|
||||
|
||||
// Update reputation score
|
||||
const { data: repData } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('*')
|
||||
.eq('user_id', counterpartyId)
|
||||
.single();
|
||||
|
||||
if (repData) {
|
||||
// Calculate new average rating
|
||||
const { data: allRatings } = await supabase
|
||||
.from('p2p_ratings')
|
||||
.select('rating')
|
||||
.eq('rated_id', counterpartyId);
|
||||
|
||||
const totalRatings = allRatings?.length || 0;
|
||||
const avgRating = allRatings
|
||||
? allRatings.reduce((sum, r) => sum + r.rating, 0) / totalRatings
|
||||
: rating;
|
||||
|
||||
// Update reputation
|
||||
await supabase
|
||||
.from('p2p_reputation')
|
||||
.update({
|
||||
reputation_score: Math.round(avgRating * 20), // Convert 5-star to 100-point scale
|
||||
})
|
||||
.eq('user_id', counterpartyId);
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_rating',
|
||||
title: 'New Rating Received',
|
||||
message: `You received a ${rating}-star rating`,
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
toast.success('Rating submitted successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Submit rating error:', error);
|
||||
toast.error('Failed to submit rating');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 transition-colors ${
|
||||
star <= (hoveredRating || rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getRatingLabel = (r: number): string => {
|
||||
switch (r) {
|
||||
case 1: return 'Poor';
|
||||
case 2: return 'Fair';
|
||||
case 3: return 'Good';
|
||||
case 4: return 'Very Good';
|
||||
case 5: return 'Excellent';
|
||||
default: return 'Select a rating';
|
||||
}
|
||||
};
|
||||
|
||||
const quickReviews = [
|
||||
{ icon: ThumbsUp, text: 'Fast payment', positive: true },
|
||||
{ icon: ThumbsUp, text: 'Good communication', positive: true },
|
||||
{ icon: ThumbsUp, text: 'Smooth transaction', positive: true },
|
||||
{ icon: ThumbsDown, text: 'Slow response', positive: false },
|
||||
{ icon: ThumbsDown, text: 'Delayed payment', positive: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rate Your Experience</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
How was your trade with {counterpartyWallet.slice(0, 6)}...{counterpartyWallet.slice(-4)}?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Star Rating */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{renderStars()}
|
||||
<p className={`text-sm font-medium ${
|
||||
rating >= 4 ? 'text-green-400' :
|
||||
rating >= 3 ? 'text-yellow-400' :
|
||||
rating >= 1 ? 'text-red-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{getRatingLabel(hoveredRating || rating)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Review Buttons */}
|
||||
<div>
|
||||
<Label className="text-gray-400 text-sm">Quick feedback (optional)</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{quickReviews.map((qr, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setReview(prev =>
|
||||
prev ? `${prev}, ${qr.text}` : qr.text
|
||||
)}
|
||||
className={`
|
||||
flex items-center gap-1 px-3 py-1.5 rounded-full text-sm
|
||||
border transition-colors
|
||||
${qr.positive
|
||||
? 'border-green-500/30 text-green-400 hover:bg-green-500/10'
|
||||
: 'border-red-500/30 text-red-400 hover:bg-red-500/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<qr.icon className="w-3 h-3" />
|
||||
{qr.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review Text */}
|
||||
<div>
|
||||
<Label htmlFor="review" className="text-gray-400 text-sm">
|
||||
Additional comments (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="review"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Share your experience..."
|
||||
className="mt-2 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 resize-none"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 text-right mt-1">
|
||||
{review.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role Badge */}
|
||||
<div className="flex items-center justify-center">
|
||||
<span className={`
|
||||
px-3 py-1 rounded-full text-xs
|
||||
${isBuyer
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}
|
||||
`}>
|
||||
Rating as {isBuyer ? 'Buyer' : 'Seller'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="border-gray-700"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || loading}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Rating'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Send,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCheck,
|
||||
Clock,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
sender_id: string;
|
||||
message: string;
|
||||
message_type: 'text' | 'image' | 'system';
|
||||
attachment_url?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TradeChatProps {
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isTradeActive: boolean;
|
||||
}
|
||||
|
||||
export function TradeChat({
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
counterpartyWallet,
|
||||
isTradeActive,
|
||||
}: TradeChatProps) {
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch messages
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_messages')
|
||||
.select('*')
|
||||
.eq('trade_id', tradeId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setMessages(data || []);
|
||||
|
||||
// Mark messages as read
|
||||
if (user && data && data.length > 0) {
|
||||
const unreadIds = data
|
||||
.filter(m => m.sender_id !== user.id && !m.is_read)
|
||||
.map(m => m.id);
|
||||
|
||||
if (unreadIds.length > 0) {
|
||||
await supabase
|
||||
.from('p2p_messages')
|
||||
.update({ is_read: true })
|
||||
.in('id', unreadIds);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch messages error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tradeId, user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, [fetchMessages]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel(`chat-${tradeId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_messages',
|
||||
filter: `trade_id=eq.${tradeId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const newMsg = payload.new as Message;
|
||||
setMessages(prev => {
|
||||
// Avoid duplicates
|
||||
if (prev.some(m => m.id === newMsg.id)) return prev;
|
||||
return [...prev, newMsg];
|
||||
});
|
||||
|
||||
// Mark as read if from counterparty
|
||||
if (user && newMsg.sender_id !== user.id) {
|
||||
supabase
|
||||
.from('p2p_messages')
|
||||
.update({ is_read: true })
|
||||
.eq('id', newMsg.id);
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [tradeId, user]);
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Send message
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessage.trim() || !user || sending) return;
|
||||
|
||||
const messageText = newMessage.trim();
|
||||
setNewMessage('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('p2p_messages').insert({
|
||||
trade_id: tradeId,
|
||||
sender_id: user.id,
|
||||
message: messageText,
|
||||
message_type: 'text',
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Create notification for counterparty
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_message',
|
||||
title: 'New Message',
|
||||
message: messageText.slice(0, 100),
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
toast.error('Failed to send message');
|
||||
setNewMessage(messageText); // Restore message
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
// Validate file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Upload to Supabase Storage
|
||||
const fileName = `${tradeId}/${Date.now()}-${file.name}`;
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('p2p-chat-images')
|
||||
.upload(fileName, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('p2p-chat-images')
|
||||
.getPublicUrl(uploadData.path);
|
||||
|
||||
// Insert message with image
|
||||
const { error: msgError } = await supabase.from('p2p_messages').insert({
|
||||
trade_id: tradeId,
|
||||
sender_id: user.id,
|
||||
message: 'Sent an image',
|
||||
message_type: 'image',
|
||||
attachment_url: urlData.publicUrl,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
if (msgError) throw msgError;
|
||||
|
||||
// Create notification
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_message',
|
||||
title: 'New Image',
|
||||
message: 'Sent an image',
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
toast.success('Image sent');
|
||||
} catch (error) {
|
||||
console.error('Upload image error:', error);
|
||||
toast.error('Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Render message
|
||||
const renderMessage = (message: Message) => {
|
||||
const isOwn = message.sender_id === user?.id;
|
||||
const isSystem = message.message_type === 'system';
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-center my-2">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-gray-800 rounded-full text-xs text-gray-400">
|
||||
<Bot className="w-3 h-3" />
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwn ? 'justify-end' : 'justify-start'} mb-3`}
|
||||
>
|
||||
<div className={`flex items-end gap-2 max-w-[75%] ${isOwn ? 'flex-row-reverse' : ''}`}>
|
||||
{!isOwn && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="bg-gray-700 text-xs">
|
||||
{counterpartyWallet.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2 rounded-2xl
|
||||
${isOwn
|
||||
? 'bg-green-600 text-white rounded-br-sm'
|
||||
: 'bg-gray-700 text-white rounded-bl-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{message.message_type === 'image' && message.attachment_url ? (
|
||||
<a
|
||||
href={message.attachment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={message.attachment_url}
|
||||
alt="Shared image"
|
||||
className="max-w-[200px] max-h-[200px] rounded-lg"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{message.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 mt-1 ${isOwn ? 'justify-end' : ''}`}>
|
||||
<span className="text-xs text-gray-500">{formatTime(message.created_at)}</span>
|
||||
{isOwn && (
|
||||
message.is_read ? (
|
||||
<CheckCheck className="w-3 h-3 text-blue-400" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3 text-gray-500" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800 h-[400px] flex flex-col">
|
||||
<CardHeader className="py-3 px-4 border-b border-gray-800">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<span>Chat</span>
|
||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-green-500 text-white rounded-full">
|
||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No messages yet</p>
|
||||
<p className="text-xs">Start the conversation</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(renderMessage)
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
{isTradeActive ? (
|
||||
<div className="p-3 border-t border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
disabled={sending}
|
||||
className="flex-1 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!newMessage.trim() || sending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border-t border-gray-800 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Chat is disabled for completed/cancelled trades
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface TradeModalProps {
|
||||
offer: P2PFiatOffer;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [amount, setAmount] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const cryptoAmount = parseFloat(amount) || 0;
|
||||
const fiatAmount = cryptoAmount * offer.price_per_unit;
|
||||
const isValidAmount = cryptoAmount > 0 && cryptoAmount <= offer.remaining_amount;
|
||||
|
||||
// Check min/max order amounts
|
||||
const meetsMinOrder = !offer.min_order_amount || cryptoAmount >= offer.min_order_amount;
|
||||
const meetsMaxOrder = !offer.max_order_amount || cryptoAmount <= offer.max_order_amount;
|
||||
|
||||
const handleInitiateTrade = async () => {
|
||||
if (!api || !selectedAccount || !user) {
|
||||
toast.error('Please connect your wallet and log in');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent self-trading
|
||||
if (offer.seller_id === user.id) {
|
||||
toast.error('You cannot trade with your own offer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidAmount) {
|
||||
toast.error('Invalid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meetsMinOrder) {
|
||||
toast.error(`Minimum order: ${offer.min_order_amount} ${offer.token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meetsMaxOrder) {
|
||||
toast.error(`Maximum order: ${offer.max_order_amount} ${offer.token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const tradeId = await acceptFiatOffer({
|
||||
api,
|
||||
account: selectedAccount,
|
||||
offerId: offer.id,
|
||||
amount: cryptoAmount
|
||||
});
|
||||
|
||||
toast.success('Trade initiated! Proceed to payment.');
|
||||
onClose();
|
||||
|
||||
// Navigate to trade page
|
||||
navigate(`/p2p/trade/${tradeId}`);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Accept offer error:', error);
|
||||
// Error toast already shown in acceptFiatOffer
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Buy {offer.token}</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Trading with {offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Price Info */}
|
||||
<div className="p-4 bg-gray-800 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-400">Price</span>
|
||||
<span className="text-xl font-bold text-green-400">
|
||||
{offer.price_per_unit.toFixed(2)} {offer.fiat_currency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Available</span>
|
||||
<span className="text-white">{offer.remaining_amount} {offer.token}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<Label htmlFor="buyAmount">Amount to Buy ({offer.token})</Label>
|
||||
<Input
|
||||
id="buyAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
{offer.min_order_amount && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Min: {offer.min_order_amount} {offer.token}
|
||||
</p>
|
||||
)}
|
||||
{offer.max_order_amount && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Max: {offer.max_order_amount} {offer.token}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calculation */}
|
||||
{cryptoAmount > 0 && (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-1">You will pay</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{fiatAmount.toFixed(2)} {offer.fiat_currency}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{!meetsMinOrder && cryptoAmount > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Minimum order: {offer.min_order_amount} {offer.token}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!meetsMaxOrder && cryptoAmount > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Maximum order: {offer.max_order_amount} {offer.token}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Payment Time Limit */}
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Payment deadline: {offer.time_limit_minutes} minutes after accepting
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="bg-gray-800 border-gray-700 hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInitiateTrade}
|
||||
disabled={!isValidAmount || !meetsMinOrder || !meetsMaxOrder || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Initiating...
|
||||
</>
|
||||
) : (
|
||||
'Accept & Continue'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ArrowUpFromLine,
|
||||
Clock,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getInternalBalances,
|
||||
requestWithdraw,
|
||||
getDepositWithdrawHistory,
|
||||
type CryptoToken,
|
||||
type InternalBalance,
|
||||
type DepositWithdrawRequest
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface WithdrawModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type WithdrawStep = 'form' | 'confirm' | 'success';
|
||||
|
||||
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
|
||||
const [step, setStep] = useState<WithdrawStep>('form');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [walletAddress, setWalletAddress] = useState('');
|
||||
const [balances, setBalances] = useState<InternalBalance[]>([]);
|
||||
const [pendingRequests, setPendingRequests] = useState<DepositWithdrawRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [requestId, setRequestId] = useState<string>('');
|
||||
|
||||
// Network fee estimate (in HEZ)
|
||||
const NETWORK_FEE = 0.01;
|
||||
const MIN_WITHDRAWAL = 0.1;
|
||||
|
||||
// Fetch balances and pending requests on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchData();
|
||||
// Pre-fill wallet address from connected account
|
||||
if (selectedAccount?.address) {
|
||||
setWalletAddress(selectedAccount.address);
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedAccount]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [balanceData, historyData] = await Promise.all([
|
||||
getInternalBalances(),
|
||||
getDepositWithdrawHistory()
|
||||
]);
|
||||
setBalances(balanceData);
|
||||
// Filter for pending withdrawal requests
|
||||
setPendingRequests(
|
||||
historyData.filter(r => r.request_type === 'withdraw' && r.status === 'pending')
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Fetch data error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setStep('form');
|
||||
setAmount('');
|
||||
setSubmitting(false);
|
||||
setRequestId('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetModal();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getAvailableBalance = (): number => {
|
||||
const balance = balances.find(b => b.token === token);
|
||||
return balance?.available_balance || 0;
|
||||
};
|
||||
|
||||
const getLockedBalance = (): number => {
|
||||
const balance = balances.find(b => b.token === token);
|
||||
return balance?.locked_balance || 0;
|
||||
};
|
||||
|
||||
const getMaxWithdrawable = (): number => {
|
||||
const available = getAvailableBalance();
|
||||
// Subtract network fee for HEZ
|
||||
if (token === 'HEZ') {
|
||||
return Math.max(0, available - NETWORK_FEE);
|
||||
}
|
||||
return available;
|
||||
};
|
||||
|
||||
const handleSetMax = () => {
|
||||
const max = getMaxWithdrawable();
|
||||
setAmount(max.toFixed(4));
|
||||
};
|
||||
|
||||
const validateWithdrawal = (): string | null => {
|
||||
const withdrawAmount = parseFloat(amount);
|
||||
|
||||
if (isNaN(withdrawAmount) || withdrawAmount <= 0) {
|
||||
return 'Please enter a valid amount';
|
||||
}
|
||||
|
||||
if (withdrawAmount < MIN_WITHDRAWAL) {
|
||||
return `Minimum withdrawal is ${MIN_WITHDRAWAL} ${token}`;
|
||||
}
|
||||
|
||||
if (withdrawAmount > getMaxWithdrawable()) {
|
||||
return 'Insufficient available balance';
|
||||
}
|
||||
|
||||
if (!walletAddress || walletAddress.length < 40) {
|
||||
return 'Please enter a valid wallet address';
|
||||
}
|
||||
|
||||
// Check for pending requests
|
||||
const hasPendingForToken = pendingRequests.some(r => r.token === token);
|
||||
if (hasPendingForToken) {
|
||||
return `You already have a pending ${token} withdrawal request`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
const error = validateWithdrawal();
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
setStep('confirm');
|
||||
};
|
||||
|
||||
const handleSubmitWithdrawal = async () => {
|
||||
const error = validateWithdrawal();
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const withdrawAmount = parseFloat(amount);
|
||||
const id = await requestWithdraw(token, withdrawAmount, walletAddress);
|
||||
setRequestId(id);
|
||||
setStep('success');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Submit withdrawal error:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormStep = () => (
|
||||
<div className="space-y-6">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Select Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HEZ">HEZ (Native)</SelectItem>
|
||||
<SelectItem value="PEZ">PEZ</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Balance Display */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Available</p>
|
||||
<p className="font-semibold text-green-500">
|
||||
{getAvailableBalance().toFixed(4)} {token}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Locked (Escrow)</p>
|
||||
<p className="font-semibold text-yellow-500">
|
||||
{getLockedBalance().toFixed(4)} {token}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div className="space-y-2">
|
||||
<Label>Withdrawal Amount</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min="0"
|
||||
step="0.0001"
|
||||
/>
|
||||
<div className="absolute right-14 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{token}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-7 text-xs"
|
||||
onClick={handleSetMax}
|
||||
>
|
||||
MAX
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min: {MIN_WITHDRAWAL} {token} | Max: {getMaxWithdrawable().toFixed(4)} {token}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wallet Address */}
|
||||
<div className="space-y-2">
|
||||
<Label>Destination Wallet Address</Label>
|
||||
<Input
|
||||
value={walletAddress}
|
||||
onChange={(e) => setWalletAddress(e.target.value)}
|
||||
placeholder="5..."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only PezkuwiChain addresses are supported
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Network Fee Info */}
|
||||
{token === 'HEZ' && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Network fee: ~{NETWORK_FEE} HEZ (deducted from withdrawal amount)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Pending Requests Warning */}
|
||||
{pendingRequests.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You have {pendingRequests.length} pending withdrawal request(s).
|
||||
Please wait for them to complete.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!amount || parseFloat(amount) <= 0}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderConfirmStep = () => {
|
||||
const withdrawAmount = parseFloat(amount);
|
||||
const receiveAmount = token === 'HEZ' ? withdrawAmount - NETWORK_FEE : withdrawAmount;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Please review your withdrawal details carefully.
|
||||
This action cannot be undone.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Token</span>
|
||||
<span className="font-semibold">{token}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Withdrawal Amount</span>
|
||||
<span className="font-semibold">{withdrawAmount.toFixed(4)} {token}</span>
|
||||
</div>
|
||||
{token === 'HEZ' && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Network Fee</span>
|
||||
<span className="text-yellow-500">-{NETWORK_FEE} HEZ</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-4 flex justify-between items-center">
|
||||
<span className="text-muted-foreground">You Will Receive</span>
|
||||
<span className="font-bold text-lg text-green-500">
|
||||
{receiveAmount.toFixed(4)} {token}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/30 border">
|
||||
<p className="text-xs text-muted-foreground mb-1">Destination Address</p>
|
||||
<p className="font-mono text-xs break-all">{walletAddress}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Processing time: Usually within 5-30 minutes</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStep('form')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitWithdrawal}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpFromLine className="h-4 w-4 mr-2" />
|
||||
Confirm Withdrawal
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSuccessStep = () => (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-green-500">
|
||||
Withdrawal Request Submitted!
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your withdrawal request has been submitted for processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Request ID</span>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{requestId.slice(0, 8)}...
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge className="bg-yellow-500/20 text-yellow-500 border-yellow-500/30">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Processing
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Amount</span>
|
||||
<span className="font-semibold">{amount} {token}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You can track your withdrawal status in the transaction history.
|
||||
Funds will arrive in your wallet within 5-30 minutes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ArrowUpFromLine className="h-5 w-5" />
|
||||
Withdraw from P2P Balance
|
||||
</DialogTitle>
|
||||
{step !== 'success' && (
|
||||
<DialogDescription>
|
||||
{step === 'form' && 'Withdraw crypto from your P2P balance to external wallet'}
|
||||
{step === 'confirm' && 'Review and confirm your withdrawal'}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'form' && renderFormStep()}
|
||||
{step === 'confirm' && renderConfirmStep()}
|
||||
{step === 'success' && renderSuccessStep()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// P2P Filter types and defaults - separate file to avoid react-refresh warning
|
||||
|
||||
export interface P2PFilters {
|
||||
// Token
|
||||
token: 'HEZ' | 'PEZ' | 'all';
|
||||
|
||||
// Fiat currency
|
||||
fiatCurrency: string | 'all';
|
||||
|
||||
// Payment methods
|
||||
paymentMethods: string[];
|
||||
|
||||
// Amount range
|
||||
minAmount: number | null;
|
||||
maxAmount: number | null;
|
||||
|
||||
// Merchant tier
|
||||
merchantTiers: ('lite' | 'super' | 'diamond')[];
|
||||
|
||||
// Completion rate
|
||||
minCompletionRate: number;
|
||||
|
||||
// Online status
|
||||
onlineOnly: boolean;
|
||||
|
||||
// Verified only
|
||||
verifiedOnly: boolean;
|
||||
|
||||
// Sort
|
||||
sortBy: 'price' | 'completion_rate' | 'trades' | 'newest';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: P2PFilters = {
|
||||
token: 'all',
|
||||
fiatCurrency: 'all',
|
||||
paymentMethods: [],
|
||||
minAmount: null,
|
||||
maxAmount: null,
|
||||
merchantTiers: [],
|
||||
minCompletionRate: 0,
|
||||
onlineOnly: false,
|
||||
verifiedOnly: false,
|
||||
sortBy: 'price',
|
||||
sortOrder: 'asc'
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { createCourse } from '@shared/lib/perwerde';
|
||||
import { uploadToIPFS } from '@shared/lib/ipfs';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface CourseCreatorProps {
|
||||
onCourseCreated: () => void;
|
||||
}
|
||||
|
||||
export function CourseCreator({ onCourseCreated }: CourseCreatorProps) {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name || !description || !content) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 1. Upload content to IPFS
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const file = new File([blob], 'course-content.md', { type: 'text/markdown' });
|
||||
|
||||
let ipfsHash: string;
|
||||
try {
|
||||
ipfsHash = await uploadToIPFS(file);
|
||||
toast.success(`Content uploaded: ${ipfsHash.slice(0, 10)}...`);
|
||||
} catch {
|
||||
toast.error('IPFS upload failed');
|
||||
return; // STOP - don't call blockchain
|
||||
}
|
||||
|
||||
// 2. Create course on blockchain
|
||||
await createCourse(api, selectedAccount, name, description, ipfsHash);
|
||||
|
||||
// 3. Reset form
|
||||
onCourseCreated();
|
||||
setName('');
|
||||
setDescription('');
|
||||
setContent('');
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to create course:', error);
|
||||
// toast already shown in createCourse()
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Create New Course</CardTitle>
|
||||
<CardDescription>Fill in the details to create a new course on the Perwerde platform.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-white">Course Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Introduction to Blockchain"
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-white">Course Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A brief summary of the course content and objectives."
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content" className="text-white">Course Content (Markdown)</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write your course material here using Markdown..."
|
||||
className="bg-gray-800 border-gray-700 text-white h-48"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateCourse}
|
||||
disabled={loading || !selectedAccount}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Course'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { GraduationCap, BookOpen, ExternalLink, Play } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { LoadingState } from '@shared/components/AsyncComponent';
|
||||
import { getCourses, enrollInCourse, type Course } from '@shared/lib/perwerde';
|
||||
import { getIPFSUrl } from '@shared/lib/ipfs';
|
||||
|
||||
interface CourseListProps {
|
||||
enrolledCourseIds: number[];
|
||||
onEnroll: () => void;
|
||||
}
|
||||
|
||||
export function CourseList({ enrolledCourseIds, onEnroll }: CourseListProps) {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const activeCourses = await getCourses('Active');
|
||||
setCourses(activeCourses);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch courses:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch courses',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const handleEnroll = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await enrollInCourse(api, selectedAccount, courseId);
|
||||
onEnroll();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Enroll failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading available courses..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
{courses.length > 0 ? `Available Courses (${courses.length})` : 'No Courses Available'}
|
||||
</h2>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<BookOpen className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-400 mb-2">No Active Courses</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Check back later for new educational content.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{courses.map((course) => {
|
||||
const isUserEnrolled = enrolledCourseIds.includes(course.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={course.id}
|
||||
className="bg-gray-900 border-gray-800 hover:border-green-500/50 transition-colors"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{course.name}</h3>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/30">
|
||||
#{course.id}
|
||||
</Badge>
|
||||
{isUserEnrolled && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/30">
|
||||
Enrolled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-4">{course.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
{course.owner.slice(0, 8)}...{course.owner.slice(-6)}
|
||||
</div>
|
||||
{course.content_link && (
|
||||
<a
|
||||
href={getIPFSUrl(course.content_link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-green-400 hover:text-green-300"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Course Materials
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{isUserEnrolled ? (
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Already Enrolled
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => handleEnroll(course.id)}
|
||||
disabled={!selectedAccount}
|
||||
>
|
||||
Enroll Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BookOpen, CheckCircle, Award } from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { LoadingState } from '@shared/components/AsyncComponent';
|
||||
import { completeCourse, type Enrollment } from '@shared/lib/perwerde';
|
||||
|
||||
interface StudentDashboardProps {
|
||||
enrollments: Enrollment[];
|
||||
loading: boolean;
|
||||
onCourseCompleted: () => void;
|
||||
}
|
||||
|
||||
export function StudentDashboard({ enrollments, loading, onCourseCompleted }: StudentDashboardProps) {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
|
||||
const handleComplete = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, let's assume a fixed number of points for completion
|
||||
const points = 10;
|
||||
await completeCourse(api, selectedAccount, courseId, points);
|
||||
onCourseCompleted();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to complete course:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading your dashboard..." />;
|
||||
}
|
||||
|
||||
const completedCourses = enrollments.filter(e => e.is_completed).length;
|
||||
const totalPoints = enrollments.reduce((sum, e) => sum + e.points_earned, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<BookOpen className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{enrollments.length}</div>
|
||||
<div className="text-sm text-gray-400">Enrolled Courses</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{completedCourses}</div>
|
||||
<div className="text-sm text-gray-400">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
||||
<Award className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{totalPoints}</div>
|
||||
<div className="text-sm text-gray-400">Total Points</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">My Courses</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enrollments.length === 0 ? (
|
||||
<p className="text-gray-400">You are not enrolled in any courses yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enrollments.map(enrollment => (
|
||||
<div key={enrollment.id} className="p-4 bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-bold text-white">Course #{enrollment.course_id}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
Enrolled on: {new Date(enrollment.enrolled_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{enrollment.is_completed ? (
|
||||
<Badge className="bg-green-500/10 text-green-400">Completed</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => handleComplete(enrollment.course_id)}>
|
||||
Mark as Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { DollarSign, Code, Users, ChevronRight, ChevronLeft, Check } from 'lucide-react';
|
||||
|
||||
interface ProposalWizardProps {
|
||||
onComplete: (proposal: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ProposalWizard: React.FC<ProposalWizardProps> = ({ onComplete, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||||
const [proposalData, setProposalData] = useState({
|
||||
title: '',
|
||||
category: '',
|
||||
summary: '',
|
||||
description: '',
|
||||
motivation: '',
|
||||
specification: '',
|
||||
budget: '',
|
||||
timeline: '',
|
||||
milestones: [''],
|
||||
risks: '',
|
||||
team: '',
|
||||
impact: '',
|
||||
metrics: ''
|
||||
});
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: 'treasury',
|
||||
name: t('proposals.templates.treasury'),
|
||||
icon: DollarSign,
|
||||
description: t('proposals.templates.treasuryDesc'),
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
id: 'technical',
|
||||
name: t('proposals.templates.technical'),
|
||||
icon: Code,
|
||||
description: t('proposals.templates.technicalDesc'),
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
name: t('proposals.templates.community'),
|
||||
icon: Users,
|
||||
description: t('proposals.templates.communityDesc'),
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ id: 1, name: t('proposals.steps.template') },
|
||||
{ id: 2, name: t('proposals.steps.basics') },
|
||||
{ id: 3, name: t('proposals.steps.details') },
|
||||
{ id: 4, name: t('proposals.steps.impact') },
|
||||
{ id: 5, name: t('proposals.steps.review') }
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onComplete({ ...proposalData, template: selectedTemplate });
|
||||
};
|
||||
|
||||
const progress = (currentStep / steps.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between mb-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`text-sm font-medium ${
|
||||
step.id <= currentStep ? 'text-green-600' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle>{steps[currentStep - 1].name}</CardTitle>
|
||||
<CardDescription>
|
||||
{currentStep === 1 && t('proposals.wizard.selectTemplate')}
|
||||
{currentStep === 2 && t('proposals.wizard.enterBasics')}
|
||||
{currentStep === 3 && t('proposals.wizard.provideDetails')}
|
||||
{currentStep === 4 && t('proposals.wizard.defineImpact')}
|
||||
{currentStep === 5 && t('proposals.wizard.reviewSubmit')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Step 1: Template Selection */}
|
||||
{currentStep === 1 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{templates.map((template) => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedTemplate === template.id
|
||||
? 'border-green-500 shadow-lg'
|
||||
: 'hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setSelectedTemplate(template.id)}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full ${template.color} flex items-center justify-center`}>
|
||||
<Icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">{template.name}</h3>
|
||||
<p className="text-sm text-gray-600">{template.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Basic Information */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">{t('proposals.fields.title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={proposalData.title}
|
||||
onChange={(e) => setProposalData({...proposalData, title: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.title')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t('proposals.fields.category')}</Label>
|
||||
<Select
|
||||
value={proposalData.category}
|
||||
onValueChange={(value) => setProposalData({...proposalData, category: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('proposals.placeholders.category')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="treasury">Treasury</SelectItem>
|
||||
<SelectItem value="technical">Technical</SelectItem>
|
||||
<SelectItem value="community">Community</SelectItem>
|
||||
<SelectItem value="governance">Governance</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="summary">{t('proposals.fields.summary')}</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={proposalData.summary}
|
||||
onChange={(e) => setProposalData({...proposalData, summary: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.summary')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Detailed Information */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="description">{t('proposals.fields.description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={proposalData.description}
|
||||
onChange={(e) => setProposalData({...proposalData, description: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.description')}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{selectedTemplate === 'treasury' && (
|
||||
<div>
|
||||
<Label htmlFor="budget">{t('proposals.fields.budget')}</Label>
|
||||
<Input
|
||||
id="budget"
|
||||
type="number"
|
||||
value={proposalData.budget}
|
||||
onChange={(e) => setProposalData({...proposalData, budget: e.target.value})}
|
||||
placeholder="Amount in HEZ"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="timeline">{t('proposals.fields.timeline')}</Label>
|
||||
<Input
|
||||
id="timeline"
|
||||
value={proposalData.timeline}
|
||||
onChange={(e) => setProposalData({...proposalData, timeline: e.target.value})}
|
||||
placeholder="e.g., 3 months"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('proposals.fields.milestones')}</Label>
|
||||
{proposalData.milestones.map((milestone, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
value={milestone}
|
||||
onChange={(e) => {
|
||||
const newMilestones = [...proposalData.milestones];
|
||||
newMilestones[index] = e.target.value;
|
||||
setProposalData({...proposalData, milestones: newMilestones});
|
||||
}}
|
||||
placeholder={`Milestone ${index + 1}`}
|
||||
className="mb-2"
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setProposalData({...proposalData, milestones: [...proposalData.milestones, '']})}
|
||||
>
|
||||
Add Milestone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Impact Assessment */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="impact">{t('proposals.fields.impact')}</Label>
|
||||
<Textarea
|
||||
id="impact"
|
||||
value={proposalData.impact}
|
||||
onChange={(e) => setProposalData({...proposalData, impact: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.impact')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="metrics">{t('proposals.fields.metrics')}</Label>
|
||||
<Textarea
|
||||
id="metrics"
|
||||
value={proposalData.metrics}
|
||||
onChange={(e) => setProposalData({...proposalData, metrics: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.metrics')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="risks">{t('proposals.fields.risks')}</Label>
|
||||
<Textarea
|
||||
id="risks"
|
||||
value={proposalData.risks}
|
||||
onChange={(e) => setProposalData({...proposalData, risks: e.target.value})}
|
||||
placeholder={t('proposals.placeholders.risks')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Review */}
|
||||
{currentStep === 5 && (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-green-200 bg-green-50 text-gray-900">
|
||||
<Check className="w-4 h-4 text-gray-900" />
|
||||
<AlertDescription className="text-gray-900">
|
||||
{t('proposals.wizard.readyToSubmit')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<span className="font-semibold">{t('proposals.fields.title')}:</span>
|
||||
<p className="text-gray-700">{proposalData.title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">{t('proposals.fields.category')}:</span>
|
||||
<p className="text-gray-700">{proposalData.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">{t('proposals.fields.summary')}:</span>
|
||||
<p className="text-gray-700">{proposalData.summary}</p>
|
||||
</div>
|
||||
{proposalData.budget && (
|
||||
<div>
|
||||
<span className="font-semibold">{t('proposals.fields.budget')}:</span>
|
||||
<p className="text-gray-700">{proposalData.budget} HEZ</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={currentStep === 1 ? onCancel : handleBack}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
{currentStep === 1 ? t('common.cancel') : t('common.back')}
|
||||
</Button>
|
||||
{currentStep < steps.length ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentStep === 1 && !selectedTemplate}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{t('common.next')}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProposalWizard;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Share2,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Twitter,
|
||||
Facebook,
|
||||
Linkedin
|
||||
} from 'lucide-react';
|
||||
|
||||
interface InviteUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClose }) => {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [inviteeAddress, setInviteeAddress] = useState('');
|
||||
const [initiating, setInitiating] = useState(false);
|
||||
const [initiateSuccess, setInitiateSuccess] = useState(false);
|
||||
const [initiateError, setInitiateError] = useState<string | null>(null);
|
||||
|
||||
// Generate referral link with user's address
|
||||
const referralLink = useMemo(() => {
|
||||
if (!selectedAccount?.address) return '';
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}/be-citizen?ref=${selectedAccount.address}`;
|
||||
}, [selectedAccount?.address]);
|
||||
|
||||
// Share text for social media
|
||||
const shareText = useMemo(() => {
|
||||
return `Join me on Digital Kurdistan (PezkuwiChain)! 🏛️\n\nBecome a citizen and get your Welati Tiki NFT.\n\nUse my referral link:\n${referralLink}`;
|
||||
}, [referralLink]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(referralLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
const encodedText = encodeURIComponent(shareText);
|
||||
const encodedUrl = encodeURIComponent(referralLink);
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
whatsapp: `https://wa.me/?text=${encodedText}`,
|
||||
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent('Join me on Digital Kurdistan! 🏛️')}`,
|
||||
twitter: `https://twitter.com/intent/tweet?text=${encodedText}`,
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
||||
email: `mailto:?subject=${encodeURIComponent('Join Digital Kurdistan')}&body=${encodedText}`,
|
||||
};
|
||||
|
||||
if (urls[platform]) {
|
||||
window.open(urls[platform], '_blank', 'width=600,height=400');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitiateReferral = async () => {
|
||||
if (!api || !selectedAccount || !inviteeAddress) {
|
||||
setInitiateError('Please enter a valid address');
|
||||
return;
|
||||
}
|
||||
|
||||
setInitiating(true);
|
||||
setInitiateError(null);
|
||||
setInitiateSuccess(false);
|
||||
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') console.log(`Initiating referral from ${selectedAccount.address} to ${inviteeAddress}...`);
|
||||
|
||||
const tx = api.tx.referral.initiateReferral(inviteeAddress);
|
||||
|
||||
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else {
|
||||
errorMessage = dispatchError.toString();
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') console.error(errorMessage);
|
||||
setInitiateError(errorMessage);
|
||||
setInitiating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Referral initiated successfully!');
|
||||
setInitiateSuccess(true);
|
||||
setInitiating(false);
|
||||
setInviteeAddress('');
|
||||
}
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Failed to initiate referral:', err);
|
||||
setInitiateError(err.message || 'Failed to initiate referral');
|
||||
setInitiating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5 text-green-500" />
|
||||
Invite Friends to PezkuwiChain
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Share your referral link. When your friends complete KYC, you'll earn trust score points!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Referral Link Display */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">Your Referral Link</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={referralLink}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Anyone who signs up with this link will be counted as your referral
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manual Referral Initiation */}
|
||||
<div className="space-y-2 bg-blue-900/20 border border-blue-600/30 rounded-lg p-4">
|
||||
<Label className="text-blue-300">Or Pre-Register a Friend (Advanced)</Label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
If you know your friend's wallet address, you can pre-register them on-chain.
|
||||
They must then complete KYC to finalize the referral.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteeAddress}
|
||||
onChange={(e) => setInviteeAddress(e.target.value)}
|
||||
placeholder="Friend's wallet address"
|
||||
className="bg-gray-800 border-gray-700 text-white font-mono text-sm placeholder:text-gray-500 placeholder:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleInitiateReferral}
|
||||
disabled={initiating || !inviteeAddress}
|
||||
className="bg-blue-600 hover:bg-blue-700 shrink-0"
|
||||
>
|
||||
{initiating ? 'Initiating...' : 'Initiate'}
|
||||
</Button>
|
||||
</div>
|
||||
{initiateSuccess && (
|
||||
<p className="text-xs text-green-400">Referral initiated successfully!</p>
|
||||
)}
|
||||
{initiateError && (
|
||||
<p className="text-xs text-red-400">{initiateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share Options */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-300">Share via</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* WhatsApp */}
|
||||
<Button
|
||||
onClick={() => handleShare('whatsapp')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-green-400"
|
||||
>
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
WhatsApp
|
||||
</Button>
|
||||
|
||||
{/* Telegram */}
|
||||
<Button
|
||||
onClick={() => handleShare('telegram')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-400"
|
||||
>
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
Telegram
|
||||
</Button>
|
||||
|
||||
{/* Twitter */}
|
||||
<Button
|
||||
onClick={() => handleShare('twitter')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-400"
|
||||
>
|
||||
<Twitter className="mr-2 h-4 w-4" />
|
||||
Twitter
|
||||
</Button>
|
||||
|
||||
{/* Facebook */}
|
||||
<Button
|
||||
onClick={() => handleShare('facebook')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-600"
|
||||
>
|
||||
<Facebook className="mr-2 h-4 w-4" />
|
||||
Facebook
|
||||
</Button>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<Button
|
||||
onClick={() => handleShare('linkedin')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-500"
|
||||
>
|
||||
<Linkedin className="mr-2 h-4 w-4" />
|
||||
LinkedIn
|
||||
</Button>
|
||||
|
||||
{/* Email */}
|
||||
<Button
|
||||
onClick={() => handleShare('email')}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-yellow-500"
|
||||
>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rewards Info */}
|
||||
<div className="bg-green-900/20 border border-green-600/30 rounded-lg p-4">
|
||||
<h4 className="text-green-400 font-semibold mb-2 text-sm">Referral Rewards</h4>
|
||||
<ul className="text-xs text-gray-300 space-y-1">
|
||||
<li>• 1-10 referrals: 10 points each (up to 100 points)</li>
|
||||
<li>• 11-50 referrals: 5 points each (up to 300 points)</li>
|
||||
<li>• 51-100 referrals: 4 points each (up to 500 points)</li>
|
||||
<li>• Maximum: 500 trust score points</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="bg-gray-800 hover:bg-gray-700 text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useReferral } from '@/contexts/ReferralContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InviteUserModal } from './InviteUserModal';
|
||||
import { Users, UserPlus, Trophy, Award, Loader2 } from 'lucide-react';
|
||||
|
||||
export const ReferralDashboard: React.FC = () => {
|
||||
const { stats, myReferrals, loading } = useReferral();
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-green-500" />
|
||||
Referral System
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Invite friends to PezkuwiChain and earn trust score
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Friend
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Referral Count */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-green-500" />
|
||||
Total Referrals
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Confirmed referrals (KYC completed)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-green-500">
|
||||
{stats?.referralCount ?? 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trust Score */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
Trust Score
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Reputation score from referrals
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-yellow-500">
|
||||
{stats?.referralScore ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Max: 500 points
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Who Invited Me */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-blue-500" />
|
||||
Invited By
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Your referrer
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats?.whoInvitedMe ? (
|
||||
<div className="text-sm font-mono text-blue-400 break-all">
|
||||
{stats.whoInvitedMe.slice(0, 8)}...{stats.whoInvitedMe.slice(-6)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">
|
||||
No referrer
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Score Calculation</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
How referrals contribute to your trust score
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-300">1-10 referrals</span>
|
||||
<span className="text-green-400 font-semibold">10 points each</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-300">11-50 referrals</span>
|
||||
<span className="text-blue-400 font-semibold">100 + 5 points each</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-300">51-100 referrals</span>
|
||||
<span className="text-yellow-400 font-semibold">300 + 4 points each</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-gray-300">101+ referrals</span>
|
||||
<span className="text-red-400 font-semibold">500 points (max)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Referrals List */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-green-500" />
|
||||
My Referrals ({myReferrals.length})
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Users you have successfully referred
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{myReferrals.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">No referrals yet</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Invite friends to start building your network
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="mt-4 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Send First Invitation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{myReferrals.map((address, index) => (
|
||||
<div
|
||||
key={address}
|
||||
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-600/20 flex items-center justify-center">
|
||||
<span className="text-green-400 font-semibold text-sm">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-mono text-white">
|
||||
{address.slice(0, 10)}...{address.slice(-8)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
KYC Completed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-green-400 text-sm font-semibold">
|
||||
+{index < 10 ? 10 : index < 50 ? 5 : index < 100 ? 4 : 0} points
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending Referral Notification */}
|
||||
{stats?.pendingReferral && (
|
||||
<Card className="bg-blue-900/20 border-blue-600/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-600/30 flex items-center justify-center">
|
||||
<Award className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-semibold">Pending Invitation</div>
|
||||
<div className="text-sm text-blue-300">
|
||||
Complete KYC to confirm your referral from{' '}
|
||||
<span className="font-mono">
|
||||
{stats.pendingReferral.slice(0, 8)}...{stats.pendingReferral.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite Modal */}
|
||||
<InviteUserModal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Shield, Save, RefreshCw, Lock, Unlock } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Record<string, boolean>;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_CATEGORIES = {
|
||||
governance: {
|
||||
title: 'Governance',
|
||||
permissions: {
|
||||
create_proposal: 'Create Proposals',
|
||||
vote_proposal: 'Vote on Proposals',
|
||||
delegate_vote: 'Delegate Voting Power',
|
||||
manage_treasury: 'Manage Treasury',
|
||||
}
|
||||
},
|
||||
moderation: {
|
||||
title: 'Moderation',
|
||||
permissions: {
|
||||
moderate_content: 'Moderate Content',
|
||||
ban_users: 'Ban Users',
|
||||
delete_posts: 'Delete Posts',
|
||||
pin_posts: 'Pin Posts',
|
||||
}
|
||||
},
|
||||
administration: {
|
||||
title: 'Administration',
|
||||
permissions: {
|
||||
manage_users: 'Manage Users',
|
||||
manage_roles: 'Manage Roles',
|
||||
view_analytics: 'View Analytics',
|
||||
system_settings: 'System Settings',
|
||||
}
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
permissions: {
|
||||
view_audit_logs: 'View Audit Logs',
|
||||
manage_sessions: 'Manage Sessions',
|
||||
configure_2fa: 'Configure 2FA',
|
||||
access_api: 'Access API',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function PermissionEditor() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('roles')
|
||||
.select('*')
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
setRoles(data || []);
|
||||
if (data && data.length > 0) {
|
||||
setSelectedRole(data[0]);
|
||||
}
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading roles:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load roles',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (category: string, permission: string) => {
|
||||
if (!selectedRole || selectedRole.is_system) return;
|
||||
|
||||
const fullPermission = `${category}.${permission}`;
|
||||
setSelectedRole({
|
||||
...selectedRole,
|
||||
permissions: {
|
||||
...selectedRole.permissions,
|
||||
[fullPermission]: !selectedRole.permissions[fullPermission]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('roles')
|
||||
.update({ permissions: selectedRole.permissions })
|
||||
.eq('id', selectedRole.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Permissions updated successfully',
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save permissions',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPermissions = () => {
|
||||
if (!selectedRole) return;
|
||||
const original = roles.find(r => r.id === selectedRole.id);
|
||||
if (original) {
|
||||
setSelectedRole(original);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Permission Editor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={selectedRole?.id} onValueChange={(id) => {
|
||||
const role = roles.find(r => r.id === id);
|
||||
if (role) setSelectedRole(role);
|
||||
}}>
|
||||
<TabsList className="grid grid-cols-4 w-full">
|
||||
{roles.map(role => (
|
||||
<TabsTrigger key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
{role.is_system && (
|
||||
<Lock className="h-3 w-3 ml-1" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{selectedRole && (
|
||||
<TabsContent value={selectedRole.id} className="space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{selectedRole.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{selectedRole.description}</p>
|
||||
{selectedRole.is_system && (
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
System Role (Read Only)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!selectedRole.is_system && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetPermissions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={savePermissions}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(PERMISSION_CATEGORIES).map(([categoryKey, category]) => (
|
||||
<div key={categoryKey} className="space-y-3">
|
||||
<h4 className="font-medium text-sm">{category.title}</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(category.permissions).map(([permKey, permName]) => {
|
||||
const fullPerm = `${categoryKey}.${permKey}`;
|
||||
const isEnabled = selectedRole.permissions[fullPerm] || false;
|
||||
|
||||
return (
|
||||
<div key={permKey} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{isEnabled ? (
|
||||
<Unlock className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{permName}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={selectedRole.is_system}
|
||||
onCheckedChange={() => togglePermission(categoryKey, permKey)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Shield, AlertTriangle, CheckCircle, XCircle, Users, Key, Activity } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
|
||||
interface SecurityMetrics {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
twoFactorEnabled: number;
|
||||
suspiciousActivities: number;
|
||||
failedLogins: number;
|
||||
securityScore: number;
|
||||
}
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
user_id: string;
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
}
|
||||
|
||||
export function SecurityAudit() {
|
||||
const [metrics, setMetrics] = useState<SecurityMetrics>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
twoFactorEnabled: 0,
|
||||
suspiciousActivities: 0,
|
||||
failedLogins: 0,
|
||||
securityScore: 0,
|
||||
});
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
// const _loading = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecurityData();
|
||||
}, []);
|
||||
|
||||
const loadSecurityData = async () => {
|
||||
try {
|
||||
// Load user metrics
|
||||
const { data: users } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, created_at');
|
||||
|
||||
const { data: twoFactor } = await supabase
|
||||
.from('two_factor_auth')
|
||||
.select('user_id')
|
||||
.eq('enabled', true);
|
||||
|
||||
const { data: sessions } = await supabase
|
||||
.from('user_sessions')
|
||||
.select('user_id')
|
||||
.eq('is_active', true);
|
||||
|
||||
const { data: logs } = await supabase
|
||||
.from('activity_logs')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
// Calculate metrics
|
||||
const totalUsers = users?.length || 0;
|
||||
const activeUsers = sessions?.length || 0;
|
||||
const twoFactorEnabled = twoFactor?.length || 0;
|
||||
const suspiciousActivities = logs?.filter(l =>
|
||||
l.action.includes('failed') || l.action.includes('suspicious')
|
||||
).length || 0;
|
||||
const failedLogins = logs?.filter(l =>
|
||||
l.action === 'login_failed'
|
||||
).length || 0;
|
||||
|
||||
// Calculate security score
|
||||
const score = Math.round(
|
||||
((twoFactorEnabled / Math.max(totalUsers, 1)) * 40) +
|
||||
((activeUsers / Math.max(totalUsers, 1)) * 20) +
|
||||
(Math.max(0, 40 - (suspiciousActivities * 2)))
|
||||
);
|
||||
|
||||
setMetrics({
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
twoFactorEnabled,
|
||||
suspiciousActivities,
|
||||
failedLogins,
|
||||
securityScore: score,
|
||||
});
|
||||
|
||||
setAuditLogs(logs || []);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading security data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-500';
|
||||
if (score >= 60) return 'text-yellow-500';
|
||||
if (score >= 40) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const getScoreBadge = (score: number) => {
|
||||
if (score >= 80) return { text: 'Excellent', variant: 'default' as const };
|
||||
if (score >= 60) return { text: 'Good', variant: 'secondary' as const };
|
||||
if (score >= 40) return { text: 'Fair', variant: 'outline' as const };
|
||||
return { text: 'Poor', variant: 'destructive' as const };
|
||||
};
|
||||
|
||||
const pieData = [
|
||||
{ name: '2FA Enabled', value: metrics.twoFactorEnabled, color: '#10b981' },
|
||||
{ name: 'No 2FA', value: metrics.totalUsers - metrics.twoFactorEnabled, color: '#ef4444' },
|
||||
];
|
||||
|
||||
const activityData = [
|
||||
{ name: 'Mon', logins: 45, failures: 2 },
|
||||
{ name: 'Tue', logins: 52, failures: 3 },
|
||||
{ name: 'Wed', logins: 48, failures: 1 },
|
||||
{ name: 'Thu', logins: 61, failures: 4 },
|
||||
{ name: 'Fri', logins: 55, failures: 2 },
|
||||
{ name: 'Sat', logins: 32, failures: 1 },
|
||||
{ name: 'Sun', logins: 28, failures: 0 },
|
||||
];
|
||||
|
||||
const scoreBadge = getScoreBadge(metrics.securityScore);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Security Score Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security Score
|
||||
</span>
|
||||
<Badge variant={scoreBadge.variant}>{scoreBadge.text}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className={`text-6xl font-bold ${getScoreColor(metrics.securityScore)}`}>
|
||||
{metrics.securityScore}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">Out of 100</p>
|
||||
</div>
|
||||
<Progress value={metrics.securityScore} className="h-3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Users</p>
|
||||
<p className="text-2xl font-bold">{metrics.totalUsers}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">2FA Enabled</p>
|
||||
<p className="text-2xl font-bold">{metrics.twoFactorEnabled}</p>
|
||||
</div>
|
||||
<Key className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Sessions</p>
|
||||
<p className="text-2xl font-bold">{metrics.activeUsers}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Suspicious</p>
|
||||
<p className="text-2xl font-bold">{metrics.suspiciousActivities}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={activityData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="logins" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.6} />
|
||||
<Area type="monotone" dataKey="failures" stroke="#ef4444" fill="#ef4444" fillOpacity={0.6} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2FA Adoption</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Security Events */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Security Events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{auditLogs.slice(0, 10).map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{log.severity === 'critical' && <XCircle className="h-5 w-5 text-red-500" />}
|
||||
{log.severity === 'high' && <AlertTriangle className="h-5 w-5 text-orange-500" />}
|
||||
{log.severity === 'medium' && <AlertTriangle className="h-5 w-5 text-yellow-500" />}
|
||||
{log.severity === 'low' && <CheckCircle className="h-5 w-5 text-green-500" />}
|
||||
<div>
|
||||
<p className="font-medium">{log.action}</p>
|
||||
<p className="text-sm text-muted-foreground">IP: {log.ip_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={
|
||||
log.severity === 'critical' ? 'destructive' :
|
||||
log.severity === 'high' ? 'destructive' :
|
||||
log.severity === 'medium' ? 'secondary' :
|
||||
'outline'
|
||||
}>
|
||||
{log.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Monitor, Shield, LogOut, Activity } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
is_active: boolean;
|
||||
profiles: {
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionMonitor() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
const interval = setInterval(loadSessions, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_sessions')
|
||||
.select(`
|
||||
*,
|
||||
profiles:user_id (username, email)
|
||||
`)
|
||||
.order('last_activity', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setSessions(data || []);
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production') console.error('Error loading sessions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const terminateSession = async (sessionId: string) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('user_sessions')
|
||||
.update({ is_active: false })
|
||||
.eq('id', sessionId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast({
|
||||
title: 'Session Terminated',
|
||||
description: 'The session has been successfully terminated.',
|
||||
});
|
||||
loadSessions();
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to terminate session',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceInfo = (userAgent: string) => {
|
||||
if (userAgent.includes('Mobile')) return 'Mobile';
|
||||
if (userAgent.includes('Tablet')) return 'Tablet';
|
||||
return 'Desktop';
|
||||
};
|
||||
|
||||
const getActivityStatus = (lastActivity: string) => {
|
||||
const diff = Date.now() - new Date(lastActivity).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 5) return { text: 'Active', variant: 'default' as const };
|
||||
if (minutes < 30) return { text: 'Idle', variant: 'secondary' as const };
|
||||
return { text: 'Inactive', variant: 'outline' as const };
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Active Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => {
|
||||
const status = getActivityStatus(session.last_activity);
|
||||
return (
|
||||
<div key={session.id} className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{session.profiles?.username}</span>
|
||||
<Badge variant={status.variant}>
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
{status.text}
|
||||
</Badge>
|
||||
{session.is_active && (
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>IP: {session.ip_address}</p>
|
||||
<p>Device: {getDeviceInfo(session.user_agent)}</p>
|
||||
<p>Started: {format(new Date(session.created_at), 'PPp')}</p>
|
||||
<p>Last Activity: {format(new Date(session.last_activity), 'PPp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{session.is_active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => terminateSession(session.id)}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-1" />
|
||||
Terminate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Monitor className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No active sessions</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
test('framework sanity check', () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user