fix: update @pezkuwi/api packages to 16.5.22

This commit is contained in:
2026-01-31 18:21:18 +03:00
parent ce73164620
commit f29340203f
263 changed files with 58350 additions and 13 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+307
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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",
+6
View File
@@ -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

+73
View File
@@ -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&apos;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&apos;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&apos;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>
);
};
+582
View File
@@ -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;
+269
View File
@@ -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&apos;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&apos;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>
);
}
+67
View File
@@ -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>&copy; {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>
);
};
+175
View File
@@ -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&apos;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 &quot;Connect Wallet&quot; 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&apos;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;
+354
View File
@@ -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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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ê ? (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 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&apos;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&apos;s current ratio. You&apos;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&apos;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>&gt; 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&apos;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>
);
};
+311
View File
@@ -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>
);
}
+46
View File
@@ -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&apos;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&apos;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&apos;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&apos;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