Reorganize repository into monorepo structure

Restructured the project to support multiple frontend applications:
- Move web app to web/ directory
- Create pezkuwi-sdk-ui/ for Polkadot SDK clone (planned)
- Create mobile/ directory for mobile app development
- Add shared/ directory with common utilities, types, and blockchain code
- Update README.md with comprehensive documentation
- Remove obsolete DKSweb/ directory

This monorepo structure enables better code sharing and organized
development across web, mobile, and SDK UI projects.
This commit is contained in:
Claude
2025-11-14 00:46:35 +00:00
parent d66e46034a
commit 24be8d4411
206 changed files with 502 additions and 4 deletions
+754
View File
@@ -0,0 +1,754 @@
import React, { useEffect, useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Wallet, TrendingUp, ArrowUpRight, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ASSET_IDS, getAssetSymbol } from '@/lib/wallet';
import { AddTokenModal } from './AddTokenModal';
import { TransferModal } from './TransferModal';
import { getAllScores, type UserScores } from '@/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 } = usePolkadot();
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 '';
}
};
// 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 {
console.log('💰 Fetching token prices from pools...');
// Import utilities for pool account derivation
const { stringToU8a } = await import('@polkadot/util');
const { blake2AsU8a } = await import('@polkadot/util-crypto');
const PALLET_ID = stringToU8a('py/ascon');
// Fetch wHEZ/wUSDT pool reserves (Asset 0 / Asset 2)
const whezPoolId = api.createType('(u32, u32)', [0, 2]);
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(2, 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));
console.log('✅ HEZ price:', hezPrice, 'USD');
setHezUsdPrice(hezPrice);
} else {
console.warn('⚠️ wHEZ/wUSDT pool has no reserves');
}
// Fetch PEZ/wUSDT pool reserves (Asset 1 / Asset 2)
const pezPoolId = api.createType('(u32, u32)', [1, 2]);
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(2, 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));
console.log('✅ PEZ price:', pezPrice, 'USD');
setPezUsdPrice(pezPrice);
} else {
console.warn('⚠️ PEZ/wUSDT pool has no reserves');
}
} catch (error) {
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 any;
// 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) {
console.error(`Failed to fetch token ${assetId}:`, error);
}
}
setOtherTokens(tokens);
} catch (error) {
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) {
console.error('Failed to fetch PEZ balance:', error);
setPezBalance('0');
}
// Fetch USDT balance (wUSDT - Asset ID: 2)
try {
const usdtAssetBalance = await api.query.assets.account(2, 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) {
console.error('Failed to fetch USDT balance:', error);
setUsdtBalance('0');
}
// Fetch token prices from pools
await fetchTokenPrices();
// Fetch other tokens
await fetchOtherTokens();
} catch (error) {
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
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) {
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) {
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) {
console.error('Failed to subscribe to USDT balance:', error);
}
};
subscribeBalance();
return () => {
if (unsubscribeHez) unsubscribeHez();
if (unsubscribePez) unsubscribePez();
if (unsubscribeUsdt) unsubscribeUsdt();
};
}, [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">
<CardTitle className="text-lg font-medium text-gray-300">
HEZ Balance
</CardTitle>
<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">
<CardTitle className="text-lg font-medium text-gray-300">
PEZ Token Balance
</CardTitle>
</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">
<CardTitle className="text-lg font-medium text-gray-300">
USDT Balance
</CardTitle>
</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 */}
<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>
);
};
+538
View File
@@ -0,0 +1,538 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Info, AlertCircle } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ASSET_IDS, getAssetSymbol } from '@/lib/wallet';
interface AddLiquidityModalProps {
isOpen: boolean;
onClose: () => void;
asset0?: number; // Pool's first asset ID
asset1?: number; // Pool's second asset ID
}
// 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 === 2) 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 === 2) return 'USDT';
return getAssetSymbol(assetId);
};
// Helper to get decimals for asset
const getAssetDecimals = (assetId: number): number => {
if (assetId === ASSET_IDS.WUSDT || assetId === 2) 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 } = usePolkadot();
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);
console.log('🔍 Querying minimum balances for assets:', { asset0, asset1 });
if (assetDetails0.isSome && assetDetails1.isSome) {
const details0 = assetDetails0.unwrap().toJSON() as any;
const details1 = assetDetails1.unwrap().toJSON() as any;
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);
console.log('📊 Minimum deposit requirements from assets:', {
asset0: asset0Name,
minBalance0Raw,
minBalance0,
asset1: asset1Name,
minBalance1Raw,
minBalance1
});
setMinDeposit0(minBalance0);
setMinDeposit1(minBalance1);
} else {
console.warn('⚠️ Asset details not found, using defaults');
}
// Also check if there's a MintMinLiquidity constant in assetConversion pallet
if (api.consts.assetConversion) {
const mintMinLiq = api.consts.assetConversion.mintMinLiquidity;
if (mintMinLiq) {
console.log('🔧 AssetConversion MintMinLiquidity constant:', mintMinLiq.toString());
}
const liquidityWithdrawalFee = api.consts.assetConversion.liquidityWithdrawalFee;
if (liquidityWithdrawalFee) {
console.log('🔧 AssetConversion LiquidityWithdrawalFee:', liquidityWithdrawalFee.toHuman());
}
// Log all assetConversion constants
console.log('🔧 All assetConversion constants:', Object.keys(api.consts.assetConversion));
}
} catch (err) {
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('@polkadot/util');
const { blake2AsU8a } = await import('@polkadot/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 any;
const data1 = balance1Data.unwrap().toJSON() as any;
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);
console.log('Pool has liquidity - auto-calculating ratio:', reserve1 / reserve0);
} else {
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool is empty or has dust only - manual input allowed');
}
} else {
// No reserves found - pool is empty
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool is empty - manual input allowed');
}
} else {
// Pool doesn't exist yet - completely empty
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool does not exist yet - manual input allowed');
}
} catch (err) {
console.error('Error fetching pool price:', err);
// On error, assume pool is empty to allow manual input
setCurrentPrice(null);
setIsPoolEmpty(true);
}
};
fetchPoolPrice();
}, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals]);
// Auto-calculate asset1 amount based on asset0 input (only if pool has liquidity)
useEffect(() => {
if (!isPoolEmpty && amount0 && currentPrice) {
const calculated = parseFloat(amount0) * currentPrice;
setAmount1(calculated.toFixed(asset1Decimals === 6 ? 2 : 4));
} else if (!amount0 && !isPoolEmpty) {
setAmount1('');
}
// If pool is empty, don't auto-calculate - let user input both amounts
}, [amount0, currentPrice, asset1Decimals, isPoolEmpty]);
const handleAddLiquidity = async () => {
if (!api || !selectedAccount || !amount0 || !amount1) return;
setIsLoading(true);
setError(null);
try {
// Validate amounts
if (parseFloat(amount0) <= 0 || parseFloat(amount1) <= 0) {
setError('Please enter valid amounts');
setIsLoading(false);
return;
}
// Check minimum deposit requirements from runtime
if (parseFloat(amount0) < minDeposit0) {
setError(`${asset0Name} amount must be at least ${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
setIsLoading(false);
return;
}
if (parseFloat(amount1) < minDeposit1) {
setError(`${asset1Name} amount must be at least ${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
setIsLoading(false);
return;
}
const balance0 = (balances as any)[asset0BalanceKey] || 0;
const balance1 = (balances as any)[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) {
console.log('Transaction in block:', status.asInBlock.toHex());
} else if (status.isFinalized) {
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(' ')}`;
console.error('Dispatch error:', errorMessage);
} else {
errorMessage = dispatchError.toString();
console.error('Dispatch error:', errorMessage);
}
}
events.forEach(({ event }) => {
if (api.events.system.ExtrinsicFailed.is(event)) {
console.error('ExtrinsicFailed event:', event.toHuman());
}
});
setError(errorMessage);
setIsLoading(false);
} else {
console.log('Transaction successful');
setSuccess(true);
setIsLoading(false);
setAmount0('');
setAmount1('');
refreshBalances();
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
}
}
}
);
} catch (err) {
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 any)[asset0BalanceKey] || 0;
const balance1 = (balances as any)[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>
);
};
+117
View File
@@ -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 (err) {
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>
);
};
+545
View File
@@ -0,0 +1,545 @@
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 TeamSection from './TeamSection';
import ChainSpecs from './ChainSpecs';
import TrustScoreCalculator from './TrustScoreCalculator';
import { NetworkStats } from './NetworkStats';
import { WalletButton } from './wallet/WalletButton';
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 { Github, FileText, ExternalLink, Shield, Award, User, FileEdit, Users2, MessageSquare, ShieldCheck, Wifi, WifiOff, Wallet, DollarSign, PiggyBank, History, Key, TrendingUp, ArrowRightLeft, Lock, LogIn, LayoutDashboard, Settings, UserCog, Repeat, Users, Droplet } 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 { P2PMarket } from './p2p/P2PMarket';
import { MultiSigWallet } from './wallet/MultiSigWallet';
import { useWallet } from '@/contexts/WalletContext';
import { supabase } from '@/lib/supabase';
import { PolkadotWalletButton } from './PolkadotWalletButton';
import { DEXDashboard } from './dex/DEXDashboard';
const AppLayout: React.FC = () => {
const navigate = useNavigate();
const [walletModalOpen, setWalletModalOpen] = useState(false);
const [transactionModalOpen, setTransactionModalOpen] = 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 [showP2P, setShowP2P] = useState(false);
const [showMultiSig, setShowMultiSig] = useState(false);
const [showDEX, setShowDEX] = useState(false);
const { t } = useTranslation();
const { isConnected } = useWebSocket();
const { account } = useWallet();
const [isAdmin, 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) {
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)}
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
>
<ArrowRightLeft className="w-4 h-4" />
P2P Market
</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={() => 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="https://raw.githubusercontent.com/pezkuwichain/DKSweb/main/public/Whitepaper.pdf"
download="Pezkuwi_Whitepaper.pdf"
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 />
<PolkadotWalletButton />
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main>
{/* Conditional Rendering for Features */}
{showDEX ? (
<DEXDashboard />
) : showProposalWizard ? (
<ProposalWizard
onComplete={(proposal) => {
console.log('Proposal created:', proposal);
setShowProposalWizard(false);
}}
onCancel={() => setShowProposalWizard(false)}
/>
) : showDelegation ? (
<DelegationManager />
) : 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>
) : showP2P ? (
<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">
P2P Trading Market
</h2>
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
Trade tokens directly with other users
</p>
</div>
<P2PMarket />
</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>
) : (
<>
<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 || showP2P || showMultiSig) && (
<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);
setShowP2P(false);
setShowMultiSig(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="mt-4 space-y-1 text-sm text-gray-400">
<p>📧 info@pezkuwichain.io</p>
<p>📧 info@pezkuwichain.app</p>
</div>
<div className="max-w-full mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<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">
{t('footer.description', 'Decentralized governance for Kurdistan')}
</p>
</div>
<div>
<h4 className="text-white font-semibold mb-4">{t('footer.about')}</h4>
<ul className="space-y-2">
<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 flex items-center"
>
{t('nav.docs')}
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</li>
<li>
<a href="#" className="text-gray-400 hover:text-white text-sm">
GitHub
</a>
</li>
</ul>
</div>
<div>
<h4 className="text-white font-semibold mb-4">{t('footer.developers')}</h4>
<ul className="space-y-2">
<li>
<a href="#" className="text-gray-400 hover:text-white text-sm">
API
</a>
</li>
<li>
<a href="#" className="text-gray-400 hover:text-white text-sm">
SDK
</a>
</li>
</ul>
</div>
<div>
<h4 className="text-white font-semibold mb-4">{t('footer.community')}</h4>
<ul className="space-y-2">
<li>
<a href="https://discord.gg/pezkuwichain" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white text-sm 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 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 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 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 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. {t('footer.rights')}
</p>
</div>
</div>
</footer>
</div>
);
};
export default AppLayout;
+257
View File
@@ -0,0 +1,257 @@
import React, { useState } from 'react';
import { Server, Globe, TestTube, Code, Wifi, Copy, Check } from 'lucide-react';
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: '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 ChainSpecs: React.FC = () => {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [selectedSpec, setSelectedSpec] = useState<ChainSpec>(chainSpecs[0]);
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">
Chain Specifications
</h2>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
Multiple network environments for development, testing, and production
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{chainSpecs.map((spec) => (
<div
key={spec.id}
onClick={() => setSelectedSpec(spec)}
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>
))}
</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">WebSocket Endpoint</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">Chain ID</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>
<label className="text-gray-400 text-sm mb-2 block">Features</label>
<div className="flex flex-wrap gap-2">
{selectedSpec.features.map((feature) => (
<span
key={feature}
className="px-3 py-1 bg-gray-900 text-gray-300 text-sm rounded-full"
>
{feature}
</span>
))}
</div>
</div>
</div>
</div>
<div>
<h4 className="text-lg font-semibold text-white mb-4">Connection Example</h4>
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm">
<div className="text-gray-400 mb-2">// Using @polkadot/api</div>
<div className="text-cyan-400">import</div>
<div className="text-white ml-2">{'{ ApiPromise, WsProvider }'}</div>
<div className="text-cyan-400">from</div>
<div className="text-green-400 mb-3">'@polkadot/api';</div>
<div className="text-cyan-400">const</div>
<div className="text-white ml-2">provider =</div>
<div className="text-cyan-400 ml-2">new</div>
<div className="text-yellow-400 ml-2">WsProvider(</div>
<div className="text-green-400 ml-4">'{selectedSpec.endpoint}'</div>
<div className="text-yellow-400">);</div>
<div className="text-cyan-400 mt-2">const</div>
<div className="text-white ml-2">api =</div>
<div className="text-cyan-400 ml-2">await</div>
<div className="text-yellow-400 ml-2">ApiPromise.create</div>
<div className="text-white">({'{ provider }'});</div>
</div>
<div className="mt-4 p-4 bg-kurdish-green/20 rounded-lg border border-kurdish-green/30">
<h5 className="text-kurdish-green font-semibold mb-2">Network Stats</h5>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">Block Time:</span>
<span className="text-white ml-2">6s</span>
</div>
<div>
<span className="text-gray-400">Finality:</span>
<span className="text-white ml-2">GRANDPA</span>
</div>
<div>
<span className="text-gray-400">Consensus:</span>
<span className="text-white ml-2">Aura</span>
</div>
<div>
<span className="text-gray-400">Runtime:</span>
<span className="text-white ml-2">v1.0.0</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default ChainSpecs;
@@ -0,0 +1,87 @@
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';
const GovernanceInterface: React.FC = () => {
const [activeTab, setActiveTab] = useState('overview');
return (
<section className="py-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4">
<span className="bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
On-Chain Governance
</span>
</h2>
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
Participate in PezkuwiChain's decentralized governance. Vote on proposals, elect representatives, and shape the future of the network.
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-3 lg:grid-cols-6 gap-2 bg-gray-900/50 p-1 rounded-lg">
<TabsTrigger value="overview" className="flex items-center space-x-2">
<TrendingUp className="w-4 h-4" />
<span>Overview</span>
</TabsTrigger>
<TabsTrigger value="proposals" className="flex items-center space-x-2">
<FileText className="w-4 h-4" />
<span>Proposals</span>
</TabsTrigger>
<TabsTrigger value="elections" className="flex items-center space-x-2">
<Users className="w-4 h-4" />
<span>Elections</span>
</TabsTrigger>
<TabsTrigger value="delegation" className="flex items-center space-x-2">
<Shield className="w-4 h-4" />
<span>Delegation</span>
</TabsTrigger>
<TabsTrigger value="voting" className="flex items-center space-x-2">
<Vote className="w-4 h-4" />
<span>My Votes</span>
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center space-x-2">
<History className="w-4 h-4" />
<span>History</span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-6">
<GovernanceOverview />
</TabsContent>
<TabsContent value="proposals" className="mt-6">
<ProposalsList />
</TabsContent>
<TabsContent value="elections" className="mt-6">
<ElectionsInterface />
</TabsContent>
<TabsContent value="delegation" className="mt-6">
<div className="text-center py-12 text-gray-400">
Delegation interface coming soon...
</div>
</TabsContent>
<TabsContent value="voting" className="mt-6">
<div className="text-center py-12 text-gray-400">
Voting history coming soon...
</div>
</TabsContent>
<TabsContent value="history" className="mt-6">
<div className="text-center py-12 text-gray-400">
Governance history coming soon...
</div>
</TabsContent>
</Tabs>
</div>
</section>
);
};
export default GovernanceInterface;
+148
View File
@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Shield } from 'lucide-react';
import { usePolkadot } from '../contexts/PolkadotContext';
import { formatBalance } from '../lib/wallet';
const HeroSection: React.FC = () => {
const { t } = useTranslation();
const { api, isApiReady } = usePolkadot();
const [stats, setStats] = useState({
activeProposals: 0,
totalVoters: 0,
tokensStaked: '0',
trustScore: 0
});
useEffect(() => {
const fetchStats = async () => {
if (!api || !isApiReady) return;
try {
// Fetch active referenda
let activeProposals = 0;
try {
const referendaCount = await api.query.referenda.referendumCount();
activeProposals = referendaCount.toNumber();
} catch (err) {
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) {
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) {
console.warn('Failed to fetch voters:', err);
}
// Update stats
setStats({
activeProposals,
totalVoters,
tokensStaked,
trustScore: 0 // TODO: Calculate trust score
});
console.log('✅ Hero stats updated:', {
activeProposals,
totalVoters,
tokensStaked
});
} catch (error) {
console.error('Failed to fetch hero stats:', error);
}
};
fetchStats();
}, [api, isApiReady]);
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;
+188
View File
@@ -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>
);
};
+59
View File
@@ -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>
);
}
+172
View File
@@ -0,0 +1,172 @@
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 { usePolkadot } from '@/contexts/PolkadotContext';
import {
getMultisigMemberInfo,
calculateMultisigAddress,
USDT_MULTISIG_CONFIG,
formatMultisigAddress,
} from '@/lib/multisig';
import { getTikiDisplayName, getTikiEmoji } from '@/lib/tiki';
interface MultisigMembersProps {
specificAddresses?: Record<string, string>;
showMultisigAddress?: boolean;
}
export const MultisigMembers: React.FC<MultisigMembersProps> = ({
specificAddresses = {},
showMultisigAddress = true,
}) => {
const { api, isApiReady } = usePolkadot();
const [members, setMembers] = useState<any[]>([]);
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) {
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://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9944#/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 Polkadot.js
<ExternalLink className="h-4 w-4" />
</a>
</div>
)}
</Card>
);
};
+206
View File
@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 } = usePolkadot();
const [blockNumber, setBlockNumber] = useState<number>(0);
const [blockHash, setBlockHash] = useState<string>('');
const [finalizedBlock, setFinalizedBlock] = useState<number>(0);
const [validatorCount, setValidatorCount] = 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, nominator count, and peer count every 3 seconds
const updateNetworkStats = async () => {
try {
const validators = await api.query.session.validators();
const health = await api.rpc.system.health();
// Count nominators
let nominatorCount = 0;
try {
const nominators = await api.query.staking.nominators.entries();
nominatorCount = nominators.length;
} catch (err) {
console.warn('Staking pallet not available, nominators = 0');
}
setValidatorCount(validators.length);
setNominatorCount(nominatorCount);
setPeers(health.peers.toNumber());
} catch (err) {
console.error('Failed to update network stats:', err);
}
};
// Initial update
await updateNetworkStats();
// Update every 3 seconds
intervalId = setInterval(updateNetworkStats, 3000);
} catch (err) {
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-5 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">
Securing the network - LIVE
</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 { usePolkadot } from '@/contexts/PolkadotContext';
import { getUserTikis } from '@/lib/citizenship-workflow';
import type { TikiInfo } from '@/lib/citizenship-workflow';
// Icon map for different Tiki roles
const getTikiIcon = (role: string) => {
const roleLower = role.toLowerCase();
if (roleLower.includes('hemwelatî') || roleLower.includes('welati') || roleLower.includes('citizen')) {
return <Shield className="w-6 h-6 text-cyan-500" />;
}
if (roleLower.includes('leader') || roleLower.includes('chief')) {
return <Crown className="w-6 h-6 text-yellow-500" />;
}
if (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('hemwelatî') || roleLower.includes('welati') || roleLower.includes('citizen')) {
return 'bg-cyan-500/10 text-cyan-500 border-cyan-500/30';
}
if (roleLower.includes('leader') || roleLower.includes('chief')) {
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
}
if (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 } = usePolkadot();
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) {
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 === 'Hemwelatî' ? 'Welati' : 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>
);
};
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { Code, Database, TrendingUp, Gift, UserCheck, Award } from 'lucide-react';
interface Pallet {
id: string;
name: string;
icon: React.ReactNode;
description: string;
image: string;
extrinsics: string[];
storage: string[];
}
const pallets: Pallet[] = [
{
id: 'pez-treasury',
name: 'PEZ Treasury',
icon: <Database className="w-6 h-6" />,
description: 'Manages token distribution with 48-month synthetic halving mechanism',
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315321470_3d093f4f.webp',
extrinsics: ['initialize_treasury', 'release_monthly_funds', 'force_genesis_distribution'],
storage: ['HalvingInfo', 'MonthlyReleases', 'TreasuryStartBlock']
},
{
id: 'trust',
name: 'Trust Score',
icon: <TrendingUp className="w-6 h-6" />,
description: 'Calculates weighted trust scores from multiple components',
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315323202_06631fb8.webp',
extrinsics: ['force_recalculate_trust_score', 'update_all_trust_scores', 'periodic_trust_score_update'],
storage: ['TrustScores', 'TotalActiveTrustScore', 'BatchUpdateInProgress']
},
{
id: 'staking-score',
name: 'Staking Score',
icon: <Award className="w-6 h-6" />,
description: 'Time-based staking multipliers from 1.0x to 2.0x over 12 months',
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315324943_84216eda.webp',
extrinsics: ['start_score_tracking'],
storage: ['StakingStartBlock']
},
{
id: 'pez-rewards',
name: 'PEZ Rewards',
icon: <Gift className="w-6 h-6" />,
description: 'Monthly epoch-based reward distribution system',
image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315326731_ca5f9a92.webp',
extrinsics: ['initialize_rewards_system', 'record_trust_score', 'finalize_epoch', 'claim_reward'],
storage: ['EpochInfo', 'EpochRewardPools', 'UserEpochScores', 'ClaimedRewards']
}
];
const PalletsGrid: React.FC = () => {
const [selectedPallet, setSelectedPallet] = useState<Pallet | null>(null);
return (
<section id="pallets" className="py-20 bg-gray-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
Core Runtime Pallets
</h2>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
Modular blockchain components powering PezkuwiChain's advanced features
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{pallets.map((pallet) => (
<div
key={pallet.id}
onClick={() => setSelectedPallet(pallet)}
className="group relative bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 hover:border-purple-500/50 transition-all cursor-pointer overflow-hidden"
>
{/* Background Glow */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-900/20 to-cyan-900/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative p-6">
<div className="flex items-start space-x-4">
<img
src={pallet.image}
alt={pallet.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<div className="flex items-center mb-2">
<div className="p-2 bg-gradient-to-br from-purple-600/20 to-cyan-600/20 rounded-lg mr-3">
{pallet.icon}
</div>
<h3 className="text-xl font-semibold text-white">{pallet.name}</h3>
</div>
<p className="text-gray-400 text-sm mb-4">{pallet.description}</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-kurdish-yellow/30 text-kurdish-yellow text-xs rounded-full">
{pallet.extrinsics.length} Extrinsics
</span>
<span className="px-2 py-1 bg-cyan-900/30 text-cyan-400 text-xs rounded-full">
{pallet.storage.length} Storage Items
</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Modal */}
{selectedPallet && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setSelectedPallet(null)}
>
<div
className="bg-gray-900 rounded-xl border border-gray-700 max-w-2xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white">{selectedPallet.name}</h3>
<button
onClick={() => setSelectedPallet(null)}
className="text-gray-400 hover:text-white"
>
</button>
</div>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold text-purple-400 mb-3">Extrinsics</h4>
<div className="space-y-2">
{selectedPallet.extrinsics.map((ext) => (
<div key={ext} className="flex items-center p-3 bg-gray-800/50 rounded-lg">
<Code className="w-4 h-4 text-cyan-400 mr-3" />
<code className="text-gray-300 font-mono text-sm">{ext}()</code>
</div>
))}
</div>
</div>
<div>
<h4 className="text-lg font-semibold text-cyan-400 mb-3">Storage Items</h4>
<div className="space-y-2">
{selectedPallet.storage.map((item) => (
<div key={item} className="flex items-center p-3 bg-gray-800/50 rounded-lg">
<Database className="w-4 h-4 text-purple-400 mr-3" />
<code className="text-gray-300 font-mono text-sm">{item}</code>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</section>
);
};
export default PalletsGrid;
+244
View File
@@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 PolkadotWalletButton: React.FC = () => {
const {
accounts,
selectedAccount,
setSelectedAccount,
connectWallet,
disconnectWallet,
error
} = usePolkadot();
const [isOpen, setIsOpen] = useState(false);
const [balance, setBalance] = useState<string>('0');
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 Polkadot 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 || 'polkadot-js'}
</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('install Polkadot.js') && (
<Dialog open={!!error} onOpenChange={() => {}}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white">Install Polkadot.js Extension</DialogTitle>
<DialogDescription className="text-gray-400">
You need the Polkadot.js browser extension to connect your wallet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<p className="text-gray-300">
The Polkadot.js extension allows you to manage your accounts and sign transactions securely.
</p>
<div className="flex gap-3">
<a
href="https://polkadot.js.org/extension/"
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<Button className="w-full bg-orange-600 hover:bg-orange-700">
<ExternalLink className="w-4 h-4 mr-2" />
Install Extension
</Button>
</a>
</div>
<p className="text-xs text-gray-500">
After installing, refresh this page and click "Connect Wallet" again.
</p>
</div>
</DialogContent>
</Dialog>
)}
<Dialog open={isOpen && accounts.length > 0} onOpenChange={setIsOpen}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white">Select Account</DialogTitle>
<DialogDescription className="text-gray-400">
Choose an account to connect
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{accounts.map((account) => (
<button
key={account.address}
onClick={() => handleSelectAccount(account)}
className="w-full p-4 rounded-lg border border-gray-700 bg-gray-800/50 hover:border-green-500/50 hover:bg-gray-800 transition-all text-left"
>
<div className="text-white font-medium mb-1">
{account.meta.name || 'Unnamed Account'}
</div>
<div className="text-gray-400 text-sm font-mono">
{account.address}
</div>
</button>
))}
</div>
</DialogContent>
</Dialog>
</>
);
};
+601
View File
@@ -0,0 +1,601 @@
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 { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { ASSET_IDS, getAssetSymbol } from '@/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 === 2) 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 } = usePolkadot();
const { balances } = useWallet();
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>('1-2'); // Default: PEZ/wUSDT
// Discover available pools
useEffect(() => {
if (!api || !isApiReady) return;
const discoverPools = async () => {
try {
// Check all possible pool combinations
const possiblePools: Array<[number, number]> = [
[ASSET_IDS.WHEZ, ASSET_IDS.PEZ], // wHEZ/PEZ
[ASSET_IDS.WHEZ, ASSET_IDS.WUSDT], // wHEZ/wUSDT
[ASSET_IDS.PEZ, ASSET_IDS.WUSDT], // PEZ/wUSDT
];
const existingPools: Array<[number, number]> = [];
for (const [asset0, asset1] of possiblePools) {
const poolInfo = await api.query.assetConversion.pools([asset0, asset1]);
if (poolInfo.isSome) {
existingPools.push([asset0, asset1]);
}
}
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) {
console.error('Error discovering pools:', err);
}
};
discoverPools();
}, [api, isApiReady]);
// 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 any;
const lpTokenId = lpTokenData.lpToken;
// Derive pool account using AccountIdConverter
const { stringToU8a } = await import('@polkadot/util');
const { blake2AsU8a } = await import('@polkadot/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 any;
reserve0 = Number(asset0Data.balance) / Math.pow(10, asset1Decimals);
}
if (asset1BalanceData.isSome) {
const asset1Data = asset1BalanceData.unwrap().toJSON() as any;
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) {
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 any;
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 any;
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) {
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;
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Loader2 } from 'lucide-react';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requireAdmin = false
}) => {
const { user, loading, isAdmin } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && !isAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
+132
View File
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 } = usePolkadot();
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(console.error);
}
}, [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 (error) {
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>
);
};
+382
View File
@@ -0,0 +1,382 @@
import React, { useState, useEffect } from 'react';
import { X, Minus, AlertCircle, Info } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ASSET_IDS, getAssetSymbol } from '@/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 === 2) 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,
lpTokenId,
asset0,
asset1,
}) => {
const { api, selectedAccount } = usePolkadot();
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 {
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 any;
const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0));
setMinBalance0(min0);
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 any;
const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0));
setMinBalance0(min0);
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 any;
const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1));
setMinBalance1(min1);
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 any;
const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1));
setMinBalance1(min1);
console.log(`📊 ${getDisplayTokenName(asset1)} minBalance: ${min1}`);
}
}
} catch (err) {
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);
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) {
console.log('Transaction in block');
} else if (status.isFinalized) {
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) {
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>
);
};
+294
View File
@@ -0,0 +1,294 @@
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 { usePolkadot } from '@/contexts/PolkadotContext';
import { getWUSDTTotalSupply, checkReserveHealth, formatWUSDT } from '@/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 } = usePolkadot();
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) {
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);
}, [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 Polkadot.js Apps</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://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9944#/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 Polkadot.js
</a>
<a
href="https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9944#/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>
);
};
+231
View File
@@ -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;
+112
View File
@@ -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
+179
View File
@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { PieChart, Clock, TrendingDown, Coins, ArrowRightLeft } from 'lucide-react';
const TokenomicsSection: React.FC = () => {
const [selectedToken, setSelectedToken] = useState<'PEZ' | 'HEZ'>('PEZ');
const [monthsPassed, setMonthsPassed] = useState(0);
const [currentRelease, setCurrentRelease] = useState(0);
const halvingPeriod = Math.floor(monthsPassed / 48);
const monthsUntilNextHalving = 48 - (monthsPassed % 48);
useEffect(() => {
const baseAmount = selectedToken === 'PEZ' ? 74218750 : 37109375;
const release = baseAmount / Math.pow(2, halvingPeriod);
setCurrentRelease(release);
}, [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;
+383
View File
@@ -0,0 +1,383 @@
import React, { useEffect, useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 } = usePolkadot();
const { toast } = useToast();
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchTransactions = async () => {
if (!api || !isApiReady || !selectedAccount) return;
setIsLoading(true);
try {
console.log('Fetching transactions...');
const currentBlock = await api.rpc.chain.getBlock();
const currentBlockNumber = currentBlock.block.header.number.toNumber();
console.log('Current block number:', currentBlockNumber);
const txList: Transaction[] = [];
const blocksToCheck = Math.min(200, currentBlockNumber);
for (let i = 0; i < blocksToCheck && txList.length < 20; i++) {
const blockNumber = currentBlockNumber - i;
try {
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
const block = await api.rpc.chain.getBlock(blockHash);
// Try to get timestamp, but don't fail if state is pruned
let timestamp = 0;
try {
const ts = await api.query.timestamp.now.at(blockHash);
timestamp = ts.toNumber();
} catch (error) {
// State pruned, use current time as fallback
timestamp = Date.now();
}
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;
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 [path, 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) {
console.warn(`Error processing block #${blockNumber}:`, blockError);
// Continue to next block
}
}
console.log('Found transactions:', txList.length);
setTransactions(txList);
} catch (error) {
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();
}
}, [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, index) => (
<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>
);
};
+334
View File
@@ -0,0 +1,334 @@
import React, { useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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;
}
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: 2, 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 } = usePolkadot();
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 === 2 ? '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('@polkadot/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, events, dispatchError }) => {
if (status.isInBlock) {
console.log(`Transaction included in block: ${status.asInBlock}`);
setTxHash(status.asInBlock.toHex());
}
if (status.isFinalized) {
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: any) {
console.error('Transfer error:', error);
setTxStatus('error');
setIsTransferring(false);
toast({
title: "Transfer Failed",
description: 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">
<div className={`w-3 h-3 rounded-full bg-gradient-to-r ${token.color}`}></div>
<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="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
className="bg-gray-800 border-gray-700 text-white mt-2"
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="0.0000"
className="bg-gray-800 border-gray-700 text-white mt-2"
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 Polkadot.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>
);
};
+252
View File
@@ -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;
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect } from 'react';
import { X, ArrowDown, ArrowUp, AlertCircle, Info, Clock, CheckCircle2 } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 '@/lib/usdt';
import { isMultisigMember } from '@/lib/multisig';
interface USDTBridgeProps {
isOpen: boolean;
onClose: () => void;
specificAddresses?: Record<string, string>;
}
export const USDTBridge: React.FC<USDTBridgeProps> = ({
isOpen,
onClose,
specificAddresses = {},
}) => {
const { api, selectedAccount, isApiReady } = usePolkadot();
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) {
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(2, 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) {
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="0.00"
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>
<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="0.00"
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"
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="Enter 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"
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>
);
};
+219
View File
@@ -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 { data, 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 { data, 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>
);
}
+148
View File
@@ -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,50 @@
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;
}
export const CitizenshipModal: React.FC<CitizenshipModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'existing' | '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>
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} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,237 @@
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 { usePolkadot } from '@/contexts/PolkadotContext';
import { verifyNftOwnership } from '@/lib/citizenship-workflow';
import { generateAuthChallenge, signChallenge, verifySignature, saveCitizenSession } from '@/lib/citizenship-crypto';
import type { AuthChallenge } from '@/lib/citizenship-crypto';
interface ExistingCitizenAuthProps {
onClose: () => void;
}
export const ExistingCitizenAuth: React.FC<ExistingCitizenAuthProps> = ({ onClose }) => {
const { api, isApiReady, selectedAccount, connectWallet } = usePolkadot();
const [tikiNumber, setTikiNumber] = 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 (!tikiNumber.trim()) {
setError('Please enter your Welati Tiki NFT number');
return;
}
setError(null);
setStep('verifying');
try {
// Verify NFT ownership
const ownsNFT = await verifyNftOwnership(api, tikiNumber, selectedAccount.address);
if (!ownsNFT) {
setError(`NFT #${tikiNumber} not found in your wallet or not a Welati Tiki`);
setStep('error');
return;
}
// Generate challenge for signature
const authChallenge = generateAuthChallenge(tikiNumber);
setChallenge(authChallenge);
setStep('signing');
} catch (err) {
console.error('Verification error:', err);
setError('Failed to verify NFT ownership');
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,
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 citizen dashboard after 2 seconds
setTimeout(() => {
// TODO: Navigate to citizen dashboard
onClose();
window.location.href = '/dashboard'; // Or use router.push('/dashboard')
}, 2000);
} catch (err) {
console.error('Signature error:', err);
setError('Failed to sign authentication challenge');
setStep('error');
}
};
const handleConnectWallet = async () => {
try {
await connectWallet();
} catch (err) {
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 Welati Tiki NFT number to authenticate
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Enter NFT Number */}
{step === 'input' && (
<>
<div className="space-y-2">
<Label htmlFor="tikiNumber">Welati Tiki NFT Number</Label>
<Input
id="tikiNumber"
placeholder="e.g., 12345"
value={tikiNumber}
onChange={(e) => setTikiNumber(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleVerifyNFT()}
/>
<p className="text-xs text-muted-foreground">
This is your unique citizen ID number received after KYC approval
</p>
</div>
{!selectedAccount ? (
<Button onClick={handleConnectWallet} className="w-full">
Connect Wallet First
</Button>
) : (
<Button onClick={handleVerifyNFT} className="w-full">
Verify NFT Ownership
</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 NFT ownership 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 #{tikiNumber}
</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,538 @@
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, Clock } from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import type { CitizenshipData, Region, MaritalStatus } from '@/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus } from '@/lib/citizenship-workflow';
import { generateCommitmentHash, generateNullifierHash, encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@/lib/citizenship-crypto';
interface NewCitizenApplicationProps {
onClose: () => void;
}
type FormData = Omit<CitizenshipData, 'walletAddress' | 'timestamp'>;
export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ onClose }) => {
const { api, isApiReady, selectedAccount, connectWallet } = usePolkadot();
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 [checkingStatus, setCheckingStatus] = useState(false);
const maritalStatus = watch('maritalStatus');
const childrenCount = watch('childrenCount');
// Check KYC status on mount
useEffect(() => {
const checkKycStatus = async () => {
if (!api || !isApiReady || !selectedAccount) {
return;
}
setCheckingStatus(true);
try {
const status = await getKycStatus(api, selectedAccount.address);
console.log('Current KYC Status:', status);
if (status === 'Approved') {
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) {
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;
}
console.log('Setting up KYC approval listener for:', selectedAccount.address);
const unsubscribe = subscribeToKycApproval(
api,
selectedAccount.address,
() => {
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) => {
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;
}
// 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);
console.log('Commitment Hash:', commitmentHash);
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);
console.log('IPFS CID:', ipfsCid);
console.log('IPFS CID type:', typeof ipfsCid);
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
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;
}
console.log('✅ KYC application submitted to blockchain');
console.log('Block hash:', result.blockHash);
// Move to waiting for approval state
setSubmitted(true);
setSubmitting(false);
setWaitingForApproval(true);
} catch (err) {
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 approval - Loading state
if (waitingForApproval) {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
{/* Animated Loader with Halos */}
<div className="relative flex items-center justify-center">
{/* Outer halo */}
<div className="absolute w-32 h-32 border-4 border-cyan-500/20 rounded-full animate-ping"></div>
{/* Middle halo */}
<div className="absolute w-24 h-24 border-4 border-purple-500/30 rounded-full animate-pulse"></div>
{/* Inner spinning sun */}
<div className="relative w-16 h-16 flex items-center justify-center">
<Loader2 className="w-16 h-16 text-cyan-500 animate-spin" />
<Clock className="absolute w-8 h-8 text-yellow-400 animate-pulse" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Waiting for Admin Approval</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your application has been submitted to the blockchain and is waiting for admin approval.
This page will automatically update when your citizenship is approved.
</p>
</div>
{/* Status steps */}
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3 text-sm">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="text-muted-foreground">Application encrypted and stored on IPFS</span>
</div>
<div className="flex items-center gap-3 text-sm">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="text-muted-foreground">Transaction submitted to blockchain</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Loader2 className="h-5 w-5 text-cyan-500 animate-spin flex-shrink-0" />
<span className="font-medium">Waiting for admin to approve KYC...</span>
</div>
<div className="flex items-center gap-3 text-sm opacity-50">
<Clock className="h-5 w-5 text-gray-400 flex-shrink-0" />
<span className="text-muted-foreground">Receive Welati Tiki NFT</span>
</div>
</div>
{/* Info */}
<Alert className="bg-cyan-500/10 border-cyan-500/30">
<AlertDescription className="text-xs">
<strong>Note:</strong> Do not close this page. The system is monitoring the blockchain
for approval events in real-time. You will be automatically redirected once approved.
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
// Initial submission success (before blockchain confirmation)
if (submitted && !waitingForApproval) {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="h-16 w-16 text-cyan-500 animate-spin" />
<h3 className="text-lg font-semibold text-center">Processing Application...</h3>
<p className="text-sm text-muted-foreground text-center max-w-md">
Encrypting your data and submitting to the blockchain. Please wait...
</p>
</CardContent>
</Card>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Personal Identity Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Nasnameya Kesane (Personal Identity)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fullName">Navê Te (Your Full Name) *</Label>
<Input {...register('fullName', { required: true })} placeholder="e.g., Berzê Ronahî" />
{errors.fullName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="fatherName">Navê Bavê Te (Father's Name) *</Label>
<Input {...register('fatherName', { required: true })} placeholder="e.g., Şêrko" />
{errors.fatherName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="grandfatherName">Navê Bavkalê Te (Grandfather's Name) *</Label>
<Input {...register('grandfatherName', { required: true })} placeholder="e.g., Welat" />
{errors.grandfatherName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="motherName">Navê Dayika Te (Mother's Name) *</Label>
<Input {...register('motherName', { required: true })} placeholder="e.g., Gula" />
{errors.motherName && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Tribal Affiliation */}
<Card>
<CardHeader>
<CardTitle>Eşîra Te (Tribal Affiliation)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="tribe">Eşîra Te (Your Tribe) *</Label>
<Input {...register('tribe', { required: true })} placeholder="e.g., Barzanî, Soran, Hewramî..." />
{errors.tribe && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Family Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="h-5 w-5" />
Rewşa Malbatê (Family Status)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Zewicî / Nezewicî (Married / Unmarried) *</Label>
<RadioGroup
onValueChange={(value) => setValue('maritalStatus', value as MaritalStatus)}
defaultValue="nezewici"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="zewici" id="married" />
<Label htmlFor="married">Zewicî (Married)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="nezewici" id="unmarried" />
<Label htmlFor="unmarried">Nezewicî (Unmarried)</Label>
</div>
</RadioGroup>
</div>
{maritalStatus === 'zewici' && (
<>
<div className="space-y-2">
<Label htmlFor="childrenCount">Hejmara Zarokan (Number of Children)</Label>
<Input
type="number"
{...register('childrenCount', { valueAsNumber: true })}
placeholder="0"
min="0"
/>
</div>
{childrenCount && childrenCount > 0 && (
<div className="space-y-3">
<Label>Navên Zarokan (Children's Names)</Label>
{Array.from({ length: childrenCount }).map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-2">
<Input
{...register(`children.${i}.name` as const)}
placeholder={`Zaroka ${i + 1} - Nav`}
/>
<Input
type="number"
{...register(`children.${i}.birthYear` as const, { valueAsNumber: true })}
placeholder="Sala Dayikbûnê"
min="1900"
max={new Date().getFullYear()}
/>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Geographic Origin */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Herêma Te (Your Region)
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="region">Ji Kuderê ? (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="Optional - Leave empty to be auto-assigned to Founder" />
<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,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);
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 PZK"
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 PZK"
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 PZK</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} PZK | Max: {profileData.maxDelegation} PZK
</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,323 @@
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 { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Users, TrendingUp, Shield, Clock, ChevronRight, Award } from 'lucide-react';
import DelegateProfile from './DelegateProfile';
const DelegationManager: React.FC = () => {
const { t } = useTranslation();
const [selectedDelegate, setSelectedDelegate] = useState<any>(null);
const [delegationAmount, setDelegationAmount] = useState('');
const [delegationPeriod, setDelegationPeriod] = useState('3months');
const delegates = [
{
id: 1,
name: 'Leyla Zana',
address: '0x1234...5678',
avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330',
reputation: 9500,
successRate: 92,
totalDelegated: 125000,
activeProposals: 8,
categories: ['Treasury', 'Community'],
description: 'Focused on community development and treasury management',
performance: {
proposalsCreated: 45,
proposalsPassed: 41,
participationRate: 98
}
},
{
id: 2,
name: 'Mazlum Doğan',
address: '0x8765...4321',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d',
reputation: 8800,
successRate: 88,
totalDelegated: 98000,
activeProposals: 6,
categories: ['Technical', 'Governance'],
description: 'Technical expert specializing in protocol upgrades',
performance: {
proposalsCreated: 32,
proposalsPassed: 28,
participationRate: 95
}
},
{
id: 3,
name: 'Sakine Cansız',
address: '0x9876...1234',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80',
reputation: 9200,
successRate: 90,
totalDelegated: 110000,
activeProposals: 7,
categories: ['Community', 'Governance'],
description: 'Community organizer with focus on inclusive governance',
performance: {
proposalsCreated: 38,
proposalsPassed: 34,
participationRate: 96
}
}
];
const myDelegations = [
{
id: 1,
delegate: 'Leyla Zana',
amount: 5000,
category: 'Treasury',
period: '3 months',
remaining: '45 days',
status: 'active'
},
{
id: 2,
delegate: 'Mazlum Doğan',
amount: 3000,
category: 'Technical',
period: '6 months',
remaining: '120 days',
status: 'active'
}
];
const handleDelegate = () => {
console.log('Delegating:', {
delegate: selectedDelegate,
amount: delegationAmount,
period: delegationPeriod
});
};
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">{t('delegation.title')}</h1>
<p className="text-gray-600">{t('delegation.description')}</p>
</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">12</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">450K</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">89%</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">8K</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.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">
<img
src={delegate.avatar}
alt={delegate.name}
className="w-12 h-12 rounded-full"
/>
<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-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" />
{(delegate.totalDelegated / 1000).toFixed(0)}K delegated
</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 PZK 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">
{myDelegations.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>
<div className="flex items-center gap-3 text-sm text-gray-600 mt-1">
<span>{delegation.amount} PZK</span>
<Badge variant="secondary">{delegation.category}</Badge>
<span>{delegation.remaining} remaining</span>
</div>
</div>
<Badge className="bg-green-100 text-green-800">
{delegation.status}
</Badge>
</div>
<Progress value={60} className="h-2" />
<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 { usePolkadot } from '@/contexts/PolkadotContext';
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 '@/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 } = usePolkadot();
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) {
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) {
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) {
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: any) {
console.error('Add liquidity failed:', error);
setErrorMessage(error.message || 'Transaction failed');
setTxStatus('error');
}
};
if (!isOpen || !pool) return null;
const shareOfPool =
amount1Input && parseFloat(amount1Input) > 0
? (
(parseFloat(
formatTokenBalance(
parseTokenInput(amount1Input, pool.asset1Decimals),
pool.asset1Decimals,
6
)
) /
(parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6)) +
parseFloat(
formatTokenBalance(
parseTokenInput(amount1Input, pool.asset1Decimals),
pool.asset1Decimals,
6
)
))) *
100
).toFixed(4)
: '0';
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
<CardHeader className="border-b border-gray-800">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-white">
Add Liquidity
</CardTitle>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="text-sm text-gray-400 mt-2">
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Info Banner */}
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-blue-400">
Add liquidity in proportion to the pool's current ratio. You'll receive LP tokens representing your share.
</span>
</div>
{/* Token 1 Input */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-gray-400">{pool.asset1Symbol}</label>
<span className="text-xs text-gray-500">
Balance: {formatTokenBalance(balance1, pool.asset1Decimals, 4)}
</span>
</div>
<div className="relative">
<input
type="text"
value={amount1Input}
onChange={(e) => handleAmount1Change(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
/>
<button
onClick={() =>
handleAmount1Change(formatTokenBalance(balance1, pool.asset1Decimals, 6))
}
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
MAX
</button>
</div>
</div>
{/* Plus Icon */}
<div className="flex justify-center">
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
<Plus className="w-5 h-5 text-green-400" />
</div>
</div>
{/* Token 2 Input */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-gray-400">{pool.asset2Symbol}</label>
<span className="text-xs text-gray-500">
Balance: {formatTokenBalance(balance2, pool.asset2Decimals, 4)}
</span>
</div>
<div className="relative">
<input
type="text"
value={amount2Input}
onChange={(e) => handleAmount2Change(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
/>
<button
onClick={() =>
handleAmount2Change(formatTokenBalance(balance2, pool.asset2Decimals, 6))
}
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
MAX
</button>
</div>
</div>
{/* Slippage Tolerance */}
<div className="space-y-2">
<label className="text-sm text-gray-400">Slippage Tolerance</label>
<div className="flex gap-2">
{[0.5, 1, 2].map((value) => (
<button
key={value}
onClick={() => setSlippage(value)}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
slippage === value
? 'bg-green-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
{value}%
</button>
))}
</div>
</div>
{/* Pool Share Preview */}
{amount1Input && amount2Input && (
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Share of Pool</span>
<span className="text-white font-mono">{shareOfPool}%</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Exchange Rate</span>
<span className="text-cyan-400 font-mono">
1 {pool.asset1Symbol} ={' '}
{(
parseFloat(formatTokenBalance(pool.reserve2, pool.asset2Decimals, 6)) /
parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6))
).toFixed(6)}{' '}
{pool.asset2Symbol}
</span>
</div>
</div>
)}
{/* Error Message */}
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-red-400">{errorMessage}</span>
</div>
)}
{/* Success Message */}
{txStatus === 'success' && (
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-green-400">
Liquidity added successfully!
</span>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<button
onClick={onClose}
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
Cancel
</button>
<button
onClick={handleAddLiquidity}
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
disabled={
txStatus === 'signing' ||
txStatus === 'submitting' ||
txStatus === 'success'
}
>
{txStatus === 'signing' && (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Signing...
</>
)}
{txStatus === 'submitting' && (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Adding...
</>
)}
{txStatus === 'idle' && 'Add Liquidity'}
{txStatus === 'error' && 'Retry'}
{txStatus === 'success' && (
<>
<CheckCircle className="w-4 h-4" />
Success
</>
)}
</button>
</div>
</CardContent>
</Card>
</div>
);
};
+413
View File
@@ -0,0 +1,413 @@
import React, { useState, useEffect } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 '@/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 } = usePolkadot();
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 {
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();
console.log('✅ Balance found for asset', asset1Id, ':', balance);
setBalance1(balance);
} else {
console.warn('⚠️ No balance found for asset', asset1Id);
setBalance1('0');
}
} catch (error) {
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 {
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();
console.log('✅ Balance found for asset', asset2Id, ':', balance);
setBalance2(balance);
} else {
console.warn('⚠️ No balance found for asset', asset2Id);
setBalance2('0');
}
} catch (error) {
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);
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: any) {
console.error('Pool creation failed:', error);
setErrorMessage(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>
);
};
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { useWallet } from '@/contexts/WalletContext';
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 { ArrowRightLeft, Droplet, Settings } from 'lucide-react';
import { isFounderWallet } from '@/utils/auth';
export const DEXDashboard: React.FC = () => {
const { account } = useWallet();
const [activeTab, setActiveTab] = useState('swap');
// Admin modal states
const [showCreatePoolModal, setShowCreatePoolModal] = useState(false);
const [showInitializeHezPoolModal, setShowInitializeHezPoolModal] = useState(false);
const isFounder = account ? isFounderWallet(account) : false;
const handleCreatePool = () => {
setShowCreatePoolModal(true);
};
const handleModalClose = () => {
setShowCreatePoolModal(false);
setShowInitializeHezPoolModal(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 Polkadot 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-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}
/>
</div>
);
};
@@ -0,0 +1,298 @@
import React, { useState, useEffect } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
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 } = usePolkadot();
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) {
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 {
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 }) => {
console.log('📦 Transaction status:', status.type);
if (status.isInBlock) {
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(' ')}`;
console.error('❌ Module error:', errorMsg);
} else {
errorMsg = dispatchError.toString();
console.error('❌ Dispatch error:', errorMsg);
}
setErrorMessage(errorMsg);
setTxStatus('error');
toast({
title: 'Transaction Failed',
description: errorMsg,
variant: 'destructive',
});
} else {
console.log('✅ Wrap successful!');
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: any) {
console.error('Wrap failed:', error);
setErrorMessage(error.message || 'Transaction failed');
setTxStatus('error');
toast({
title: 'Error',
description: 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>
);
};
+253
View File
@@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, Droplet, BarChart3, Search, Plus } from 'lucide-react';
import { PoolInfo } from '@/types/dex';
import { fetchPools, formatTokenBalance } from '@/utils/dex';
import { isFounderWallet } from '@/utils/auth';
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 } = usePolkadot();
const { account } = useWallet();
const [pools, setPools] = useState<PoolInfo[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'tvl' | 'volume' | 'apr'>('tvl');
const isFounder = account ? isFounderWallet(account.address) : false;
useEffect(() => {
const loadPools = async () => {
if (!api || !isApiReady) return;
try {
setLoading(true);
const poolsData = await fetchPools(api);
setPools(poolsData);
} catch (error) {
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 (
<div className="flex items-center justify-center py-12">
<div className="text-gray-400">Loading pools...</div>
</div>
);
}
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 { usePolkadot } from '@/contexts/PolkadotContext';
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 '@/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 } = usePolkadot();
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
const lpAssetId = api.query.assetConversion.nextPoolAssetId
? await api.query.assetConversion.nextPoolAssetId()
: null;
// This is a simplified version - you'd need to track LP tokens properly
setLpTokenBalance('0'); // Placeholder
} catch (error) {
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: any) {
console.error('Remove liquidity failed:', error);
setErrorMessage(error.message || 'Transaction failed');
setTxStatus('error');
}
};
if (!isOpen || !pool) return null;
const { amount1, amount2 } = calculateOutputAmounts();
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
<CardHeader className="border-b border-gray-800">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-white">
Remove Liquidity
</CardTitle>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="text-sm text-gray-400 mt-2">
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Info Banner */}
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-blue-400">
Remove liquidity to receive your tokens back. You'll burn LP tokens in proportion to your withdrawal.
</span>
</div>
{/* LP Token Balance */}
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
<div className="text-sm text-gray-400 mb-1">Your LP Tokens</div>
<div className="text-2xl font-bold text-white font-mono">
{formatTokenBalance(lpTokenBalance, 12, 6)}
</div>
</div>
{/* Percentage Selector */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm text-gray-400">Remove Amount</label>
<span className="text-lg font-bold text-white">{removePercentage}%</span>
</div>
<input
type="range"
min="1"
max="100"
value={removePercentage}
onChange={(e) => setRemovePercentage(Number(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
/>
<div className="grid grid-cols-4 gap-2">
{[25, 50, 75, 100].map((value) => (
<button
key={value}
onClick={() => setRemovePercentage(value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
removePercentage === value
? 'bg-green-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
{value}%
</button>
))}
</div>
</div>
{/* Divider */}
<div className="flex justify-center">
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
<Minus className="w-5 h-5 text-red-400" />
</div>
</div>
{/* Output Preview */}
<div className="space-y-3">
<div className="text-sm text-gray-400 mb-2">You will receive</div>
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">{pool.asset1Symbol}</span>
<span className="text-white font-mono text-lg">
{formatTokenBalance(amount1, pool.asset1Decimals, 6)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">{pool.asset2Symbol}</span>
<span className="text-white font-mono text-lg">
{formatTokenBalance(amount2, pool.asset2Decimals, 6)}
</span>
</div>
</div>
</div>
{/* Slippage Tolerance */}
<div className="space-y-2">
<label className="text-sm text-gray-400">Slippage Tolerance</label>
<div className="flex gap-2">
{[0.5, 1, 2].map((value) => (
<button
key={value}
onClick={() => setSlippage(value)}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
slippage === value
? 'bg-green-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
{value}%
</button>
))}
</div>
</div>
{/* Error Message */}
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-red-400">{errorMessage}</span>
</div>
)}
{/* Success Message */}
{txStatus === 'success' && (
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-green-400">
Liquidity removed successfully!
</span>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<button
onClick={onClose}
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
disabled={txStatus === 'signing' || txStatus === 'submitting'}
>
Cancel
</button>
<button
onClick={handleRemoveLiquidity}
className="flex-1 px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
disabled={
txStatus === 'signing' ||
txStatus === 'submitting' ||
txStatus === 'success' ||
BigInt(lpTokenBalance) === BigInt(0)
}
>
{txStatus === 'signing' && (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Signing...
</>
)}
{txStatus === 'submitting' && (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Removing...
</>
)}
{txStatus === 'idle' && 'Remove Liquidity'}
{txStatus === 'error' && 'Retry'}
{txStatus === 'success' && (
<>
<CheckCircle className="w-4 h-4" />
Success
</>
)}
</button>
</div>
</CardContent>
</Card>
</div>
);
};
+641
View File
@@ -0,0 +1,641 @@
import React, { useState, useEffect } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { ArrowDownUp, AlertCircle, Loader2, CheckCircle, 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 '@/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: 2, name: 'USDT', decimals: 6, displaySymbol: 'USDT' },
] as const;
export const SwapInterface: React.FC<SwapInterfaceProps> = ({ initialPool, pools }) => {
const { api, isApiReady } = usePolkadot();
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) {
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) {
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) {
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) {
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) {
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 tempAmount = fromAmount;
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
);
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: any) {
console.error('Swap failed:', error);
setErrorMessage(error.message || 'Transaction failed');
setTxStatus('error');
toast({
title: 'Error',
description: 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,386 @@
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, Award, TrendingUp, AlertTriangle, MoreVertical, Flag, Edit, Trash2, Loader2 } 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 { t } = useTranslation();
const { toast } = useToast();
const { subscribe, unsubscribe, sendMessage, isConnected } = useWebSocket();
const [isLoading, setIsLoading] = useState(false);
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: any) => {
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: any }) => {
if (data.proposalId === proposalId) {
// Update sentiment visualization in parent component
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);
};
}, [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(),
});
}
}, [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>
);
}
+250
View File
@@ -0,0 +1,250 @@
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 { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { TrendingUp, TrendingDown, MessageSquare, Users, BarChart3, Search, Filter, Clock, Flame, Award } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DiscussionThread } from './DiscussionThread';
interface Discussion {
id: string;
title: string;
proposalId: string;
author: string;
category: string;
replies: number;
views: number;
lastActivity: string;
sentiment: number;
trending: boolean;
pinned: boolean;
tags: string[];
}
export function ForumOverview() {
const { t } = useTranslation();
const [selectedDiscussion, setSelectedDiscussion] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('recent');
const [filterCategory, setFilterCategory] = useState('all');
const discussions: Discussion[] = [
{
id: '1',
title: 'Treasury Allocation for Developer Grants - Q1 2024',
proposalId: 'prop-001',
author: 'Dr. Rojin Ahmed',
category: 'Treasury',
replies: 45,
views: 1234,
lastActivity: '2 hours ago',
sentiment: 72,
trending: true,
pinned: true,
tags: ['treasury', 'grants', 'development']
},
{
id: '2',
title: 'Technical Upgrade: Implementing Zero-Knowledge Proofs',
proposalId: 'prop-002',
author: 'Kawa Mustafa',
category: 'Technical',
replies: 28,
views: 890,
lastActivity: '5 hours ago',
sentiment: 85,
trending: true,
pinned: false,
tags: ['technical', 'zkp', 'privacy']
},
{
id: '3',
title: 'Community Initiative: Education Program for New Users',
proposalId: 'prop-003',
author: 'Dilan Karim',
category: 'Community',
replies: 62,
views: 2100,
lastActivity: '1 day ago',
sentiment: 45,
trending: false,
pinned: false,
tags: ['community', 'education', 'onboarding']
}
];
const sentimentStats = {
positive: 42,
neutral: 35,
negative: 23
};
const getSentimentColor = (sentiment: number) => {
if (sentiment >= 70) return 'text-green-600';
if (sentiment >= 40) return 'text-yellow-600';
return 'text-red-600';
};
const getSentimentIcon = (sentiment: number) => {
if (sentiment >= 70) return <TrendingUp className="h-4 w-4" />;
if (sentiment >= 40) return <BarChart3 className="h-4 w-4" />;
return <TrendingDown className="h-4 w-4" />;
};
if (selectedDiscussion) {
return (
<div className="space-y-6">
<Button
variant="outline"
onClick={() => setSelectedDiscussion(null)}
>
Back to Forum
</Button>
<DiscussionThread proposalId={selectedDiscussion} />
</div>
);
}
return (
<div className="space-y-6">
{/* Sentiment Overview */}
<Card>
<CardHeader>
<CardTitle>Community Sentiment Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Positive</span>
<span className="text-sm text-green-600">{sentimentStats.positive}%</span>
</div>
<Progress value={sentimentStats.positive} className="h-2 bg-green-100" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Neutral</span>
<span className="text-sm text-yellow-600">{sentimentStats.neutral}%</span>
</div>
<Progress value={sentimentStats.neutral} className="h-2 bg-yellow-100" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Negative</span>
<span className="text-sm text-red-600">{sentimentStats.negative}%</span>
</div>
<Progress value={sentimentStats.negative} className="h-2 bg-red-100" />
</div>
</div>
</CardContent>
</Card>
{/* Search and Filters */}
<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="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="treasury">Treasury</SelectItem>
<SelectItem value="technical">Technical</SelectItem>
<SelectItem value="community">Community</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="sentiment">Best Sentiment</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Discussions List */}
<div className="space-y-4">
{discussions.map((discussion) => (
<Card
key={discussion.id}
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setSelectedDiscussion(discussion.proposalId)}
>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{discussion.pinned && (
<Badge variant="secondary">
📌 Pinned
</Badge>
)}
{discussion.trending && (
<Badge variant="destructive">
<Flame className="h-3 w-3 mr-1" />
Trending
</Badge>
)}
<Badge variant="outline">{discussion.category}</Badge>
</div>
<h3 className="text-lg font-semibold mb-2">{discussion.title}</h3>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>by {discussion.author}</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-4 w-4" />
{discussion.replies} replies
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{discussion.views} views
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{discussion.lastActivity}
</span>
</div>
<div className="flex gap-2 mt-3">
{discussion.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
#{tag}
</Badge>
))}
</div>
</div>
<div className="text-center ml-6">
<div className={`text-2xl font-bold ${getSentimentColor(discussion.sentiment)}`}>
{discussion.sentiment}%
</div>
<div className="flex items-center gap-1 text-sm text-gray-600">
{getSentimentIcon(discussion.sentiment)}
<span>Sentiment</span>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -0,0 +1,247 @@
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 { Textarea } from '@/components/ui/textarea';
import { AlertTriangle, Shield, Ban, CheckCircle, Clock, Flag, User, MessageSquare, TrendingUp } 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 { t } = useTranslation();
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,222 @@
import React, { useState } 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 { Users, Vote, Trophy, Clock, AlertCircle, CheckCircle } from 'lucide-react';
interface Election {
id: number;
type: 'Presidential' | 'Parliamentary' | 'Constitutional Court';
status: 'Registration' | 'Campaign' | 'Voting' | 'Completed';
candidates: Candidate[];
totalVotes: number;
endBlock: number;
currentBlock: number;
}
interface Candidate {
id: string;
name: string;
votes: number;
percentage: number;
party?: string;
trustScore: number;
}
const ElectionsInterface: React.FC = () => {
const [selectedElection, setSelectedElection] = useState<Election | null>(null);
const [votedCandidates, setVotedCandidates] = useState<string[]>([]);
const activeElections: Election[] = [
{
id: 1,
type: 'Presidential',
status: 'Voting',
totalVotes: 45678,
endBlock: 1000000,
currentBlock: 995000,
candidates: [
{ id: '1', name: 'Candidate A', votes: 23456, percentage: 51.3, trustScore: 850 },
{ id: '2', name: 'Candidate B', votes: 22222, percentage: 48.7, trustScore: 780 }
]
},
{
id: 2,
type: 'Parliamentary',
status: 'Registration',
totalVotes: 0,
endBlock: 1200000,
currentBlock: 995000,
candidates: []
}
];
const handleVote = (candidateId: string, electionType: string) => {
if (electionType === 'Parliamentary') {
setVotedCandidates(prev =>
prev.includes(candidateId)
? prev.filter(id => id !== candidateId)
: [...prev, candidateId]
);
} else {
setVotedCandidates([candidateId]);
}
};
return (
<div className="space-y-6">
<Tabs defaultValue="active" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="active">Active Elections</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
<TabsTrigger value="results">Results</TabsTrigger>
</TabsList>
<TabsContent value="active" className="space-y-4">
{activeElections.map(election => (
<Card key={election.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>{election.type} Election</CardTitle>
<CardDescription>
{election.status === 'Voting'
? `${election.totalVotes.toLocaleString()} votes cast`
: `Registration ends in ${(election.endBlock - election.currentBlock).toLocaleString()} blocks`}
</CardDescription>
</div>
<Badge variant={election.status === 'Voting' ? 'default' : 'secondary'}>
{election.status}
</Badge>
</div>
</CardHeader>
<CardContent>
{election.status === 'Voting' && (
<div className="space-y-4">
{election.candidates.map(candidate => (
<div key={candidate.id} className="space-y-2">
<div className="flex justify-between items-center">
<div>
<p className="font-medium">{candidate.name}</p>
<p className="text-sm text-muted-foreground">
Trust Score: {candidate.trustScore}
</p>
</div>
<div className="text-right">
<p className="font-bold">{candidate.percentage}%</p>
<p className="text-sm text-muted-foreground">
{candidate.votes.toLocaleString()} votes
</p>
</div>
</div>
<Progress value={candidate.percentage} className="h-2" />
<Button
size="sm"
variant={votedCandidates.includes(candidate.id) ? "default" : "outline"}
onClick={() => handleVote(candidate.id, election.type)}
className="w-full"
>
{votedCandidates.includes(candidate.id) ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Voted
</>
) : (
<>
<Vote className="w-4 h-4 mr-2" />
Vote
</>
)}
</Button>
</div>
))}
{election.type === 'Parliamentary' && (
<p className="text-sm text-muted-foreground text-center">
You can select multiple candidates
</p>
)}
</div>
)}
</CardContent>
</Card>
))}
</TabsContent>
<TabsContent value="register">
<Card>
<CardHeader>
<CardTitle>Candidate Registration</CardTitle>
<CardDescription>
Register as a candidate for upcoming elections
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<p className="font-medium text-amber-900 dark:text-amber-100">
Requirements
</p>
<ul className="text-sm text-amber-800 dark:text-amber-200 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" size="lg">
Register as Candidate
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="results">
<Card>
<CardHeader>
<CardTitle>Election Results</CardTitle>
<CardDescription>Historical election outcomes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 border rounded-lg">
<div className="flex justify-between items-start mb-3">
<div>
<p className="font-medium">Presidential Election 2024</p>
<p className="text-sm text-muted-foreground">Completed 30 days ago</p>
</div>
<Badge variant="outline">
<Trophy className="w-3 h-3 mr-1" />
Completed
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Winner: Candidate A</span>
<span className="font-bold">52.8%</span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Total Votes</span>
<span>89,234</span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Turnout</span>
<span>67.5%</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default ElectionsInterface;
@@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react';
import {
Vote, Users, Gavel, FileText, TrendingUpIcon,
Clock, CheckCircle, XCircle, AlertCircle,
BarChart3, 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 { usePolkadot } from '../../contexts/PolkadotContext';
import { formatBalance } from '../../lib/wallet';
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 } = usePolkadot();
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) {
console.log('API not ready for governance data');
return;
}
try {
console.log('📊 Fetching governance data from blockchain...');
setLoading(true);
// Fetch active referenda (proposals)
let activeProposals = 0;
try {
const referendaCount = await api.query.referenda.referendumCount();
console.log('Referenda count:', referendaCount.toNumber());
activeProposals = referendaCount.toNumber();
} catch (err) {
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`;
console.log('Treasury balance:', treasuryBalance);
} catch (err) {
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;
console.log('Council members:', parliamentMembers);
} catch (err) {
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
});
console.log('✅ Governance data updated:', {
activeProposals,
parliamentMembers,
treasuryBalance
});
} catch (error) {
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" />;
}
};
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,156 @@
import React, { useState } from 'react';
import { FileText, Vote, Clock, TrendingUp, Users, AlertCircle } 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';
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] = useState<Proposal[]>([
{
id: 1,
title: 'Treasury Allocation for Development Fund',
description: 'Allocate 500,000 PEZ for ecosystem development',
proposer: '5GrwvaEF...',
type: 'treasury',
status: 'active',
ayeVotes: 156,
nayVotes: 45,
totalVotes: 201,
quorum: 60,
deadline: '2 days',
requestedAmount: '500,000 PEZ'
},
{
id: 2,
title: 'Update Staking Parameters',
description: 'Increase minimum stake requirement to 1000 HEZ',
proposer: '5FHneW46...',
type: 'executive',
status: 'active',
ayeVotes: 89,
nayVotes: 112,
totalVotes: 201,
quorum: 60,
deadline: '5 days'
}
]);
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>;
}
};
return (
<div className="space-y-4">
{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,206 @@
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();
}
}, [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,265 @@
import React, { useState, useEffect } from 'react';
import { Bell, MessageCircle, AtSign, Heart, Award, TrendingUp, X, Check, Settings } 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';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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: any) => {
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: any) => {
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);
};
}, [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>
);
};
+798
View File
@@ -0,0 +1,798 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, 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 { ArrowUpDown, Search, Filter, TrendingUp, TrendingDown, User, Shield, Clock, DollarSign, Plus, X, SlidersHorizontal, Lock, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface P2POffer {
id: string;
type: 'buy' | 'sell';
token: 'HEZ' | 'PEZ';
amount: number;
price: number;
paymentMethod: string;
seller: {
name: string;
rating: number;
completedTrades: number;
verified: boolean;
};
minOrder: number;
maxOrder: number;
timeLimit: number;
}
export const P2PMarket: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'buy' | 'sell'>('buy');
const [selectedToken, setSelectedToken] = useState<'HEZ' | 'PEZ'>('HEZ');
const [searchTerm, setSearchTerm] = useState('');
const [selectedOffer, setSelectedOffer] = useState<P2POffer | null>(null);
const [tradeAmount, setTradeAmount] = useState('');
// Advanced filters
const [paymentMethodFilter, setPaymentMethodFilter] = useState<string>('all');
const [minPrice, setMinPrice] = useState<string>('');
const [maxPrice, setMaxPrice] = useState<string>('');
const [sortBy, setSortBy] = useState<'price' | 'rating' | 'trades'>('price');
const [showFilters, setShowFilters] = useState(false);
// Order creation
const [showCreateOrder, setShowCreateOrder] = useState(false);
const [newOrderAmount, setNewOrderAmount] = useState('');
const [newOrderPrice, setNewOrderPrice] = useState('');
const [newOrderPaymentMethod, setNewOrderPaymentMethod] = useState('Bank Transfer');
const offers: P2POffer[] = [
{
id: '1',
type: 'sell',
token: 'HEZ',
amount: 10000,
price: 0.95,
paymentMethod: 'Bank Transfer',
seller: {
name: 'CryptoTrader',
rating: 4.8,
completedTrades: 234,
verified: true
},
minOrder: 100,
maxOrder: 5000,
timeLimit: 30
},
{
id: '2',
type: 'sell',
token: 'HEZ',
amount: 5000,
price: 0.96,
paymentMethod: 'PayPal',
seller: {
name: 'TokenMaster',
rating: 4.9,
completedTrades: 567,
verified: true
},
minOrder: 50,
maxOrder: 2000,
timeLimit: 15
},
{
id: '3',
type: 'buy',
token: 'PEZ',
amount: 15000,
price: 1.02,
paymentMethod: 'Crypto',
seller: {
name: 'PezWhale',
rating: 4.7,
completedTrades: 123,
verified: false
},
minOrder: 500,
maxOrder: 10000,
timeLimit: 60
},
{
id: '4',
type: 'sell',
token: 'PEZ',
amount: 8000,
price: 1.01,
paymentMethod: 'Wire Transfer',
seller: {
name: 'QuickTrade',
rating: 4.6,
completedTrades: 89,
verified: true
},
minOrder: 200,
maxOrder: 3000,
timeLimit: 45
}
];
// Payment methods list
const paymentMethods = ['Bank Transfer', 'PayPal', 'Crypto', 'Wire Transfer', 'Cash', 'Mobile Money'];
// Advanced filtering and sorting
const filteredOffers = offers
.filter(offer => {
// Basic filters
if (offer.type !== activeTab) return false;
if (offer.token !== selectedToken) return false;
if (searchTerm && !offer.seller.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
// Payment method filter
if (paymentMethodFilter !== 'all' && offer.paymentMethod !== paymentMethodFilter) return false;
// Price range filter
if (minPrice && offer.price < parseFloat(minPrice)) return false;
if (maxPrice && offer.price > parseFloat(maxPrice)) return false;
return true;
})
.sort((a, b) => {
// Sorting logic
if (sortBy === 'price') {
return activeTab === 'buy' ? a.price - b.price : b.price - a.price;
} else if (sortBy === 'rating') {
return b.seller.rating - a.seller.rating;
} else if (sortBy === 'trades') {
return b.seller.completedTrades - a.seller.completedTrades;
}
return 0;
});
// Escrow state
const [showEscrow, setShowEscrow] = useState(false);
const [escrowStep, setEscrowStep] = useState<'funding' | 'confirmation' | 'release'>('funding');
const [escrowOffer, setEscrowOffer] = useState<P2POffer | null>(null);
const handleTrade = (offer: P2POffer) => {
console.log('Initiating trade:', tradeAmount, offer.token, 'with', offer.seller.name);
setEscrowOffer(offer);
setShowEscrow(true);
setEscrowStep('funding');
};
const handleEscrowFund = () => {
console.log('Funding escrow with:', tradeAmount, escrowOffer?.token);
setEscrowStep('confirmation');
};
const handleEscrowConfirm = () => {
console.log('Confirming payment received');
setEscrowStep('release');
};
const handleEscrowRelease = () => {
console.log('Releasing escrow funds');
setShowEscrow(false);
setSelectedOffer(null);
setEscrowOffer(null);
setEscrowStep('funding');
};
return (
<div className="space-y-6">
{/* Market Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">HEZ Price</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$0.95</div>
<div className="flex items-center text-green-500 text-xs mt-1">
<TrendingUp className="w-3 h-3 mr-1" />
+2.3%
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">PEZ Price</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$1.02</div>
<div className="flex items-center text-red-500 text-xs mt-1">
<TrendingDown className="w-3 h-3 mr-1" />
-0.8%
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">24h Volume</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$2.4M</div>
<p className="text-xs text-gray-500 mt-1">1,234 trades</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Active Offers</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">342</div>
<p className="text-xs text-gray-500 mt-1">89 verified sellers</p>
</CardContent>
</Card>
</div>
{/* P2P Trading Interface */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-xl text-white">P2P Market</CardTitle>
<CardDescription className="text-gray-400">
Buy and sell tokens directly with other users
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Top Action Bar */}
<div className="flex justify-between items-center">
<Button
onClick={() => setShowCreateOrder(true)}
className="bg-green-600 hover:bg-green-700"
>
<Plus className="w-4 h-4 mr-2" />
Create Order
</Button>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="border-gray-700"
>
<SlidersHorizontal className="w-4 h-4 mr-2" />
{showFilters ? 'Hide Filters' : 'Show Filters'}
</Button>
</div>
{/* Basic Filters */}
<div className="flex flex-wrap gap-4">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'buy' | 'sell')} className="flex-1">
<TabsList className="grid w-full max-w-[200px] grid-cols-2">
<TabsTrigger value="buy">Buy</TabsTrigger>
<TabsTrigger value="sell">Sell</TabsTrigger>
</TabsList>
</Tabs>
<Select value={selectedToken} onValueChange={(v) => setSelectedToken(v as 'HEZ' | 'PEZ')}>
<SelectTrigger className="w-[120px] bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
<div className="flex-1 max-w-xs">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search sellers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-gray-800 border-gray-700"
/>
</div>
</div>
{/* Sort Selector */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'price' | 'rating' | 'trades')}>
<SelectTrigger className="w-[150px] bg-gray-800 border-gray-700">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="price">Price</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
<SelectItem value="trades">Trades</SelectItem>
</SelectContent>
</Select>
</div>
{/* Advanced Filters Panel (Binance P2P style) */}
{showFilters && (
<Card className="bg-gray-800 border-gray-700 p-4">
<div className="space-y-4">
<h4 className="font-semibold text-white flex items-center gap-2">
<Filter className="w-4 h-4" />
Advanced Filters
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Payment Method Filter */}
<div>
<Label className="text-sm text-gray-400">Payment Method</Label>
<Select value={paymentMethodFilter} onValueChange={setPaymentMethodFilter}>
<SelectTrigger className="bg-gray-900 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
{paymentMethods.map(method => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Min Price Filter */}
<div>
<Label className="text-sm text-gray-400">Min Price ($)</Label>
<Input
type="number"
placeholder="Min"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
{/* Max Price Filter */}
<div>
<Label className="text-sm text-gray-400">Max Price ($)</Label>
<Input
type="number"
placeholder="Max"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
</div>
{/* Clear Filters Button */}
<Button
variant="outline"
size="sm"
onClick={() => {
setPaymentMethodFilter('all');
setMinPrice('');
setMaxPrice('');
setSearchTerm('');
}}
className="border-gray-700"
>
<X className="w-3 h-3 mr-1" />
Clear All Filters
</Button>
</div>
</Card>
)}
{/* Offers List */}
<div className="space-y-3">
{filteredOffers.map((offer) => (
<Card key={offer.id} className="bg-gray-800 border-gray-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-gray-700 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-gray-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-white">{offer.seller.name}</span>
{offer.seller.verified && (
<Badge variant="secondary" className="bg-blue-600/20 text-blue-400">
<Shield className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span> {offer.seller.rating}</span>
<span>{offer.seller.completedTrades} trades</span>
<span>{offer.paymentMethod}</span>
</div>
</div>
</div>
<div className="text-right space-y-1">
<div className="text-lg font-bold text-white">
${offer.price} / {offer.token}
</div>
<div className="text-sm text-gray-400">
Available: {offer.amount.toLocaleString()} {offer.token}
</div>
<div className="text-xs text-gray-500">
Limits: {offer.minOrder} - {offer.maxOrder} {offer.token}
</div>
</div>
<Button
className="ml-4 bg-green-600 hover:bg-green-700"
onClick={() => setSelectedOffer(offer)}
>
{activeTab === 'buy' ? 'Buy' : 'Sell'} {offer.token}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</CardContent>
</Card>
{/* Trade Modal */}
{selectedOffer && (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle>
{activeTab === 'buy' ? 'Buy' : 'Sell'} {selectedOffer.token} from {selectedOffer.seller.name}
</CardTitle>
<CardDescription>Complete your P2P trade</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Amount ({selectedOffer.token})</Label>
<Input
type="number"
placeholder={`Min: ${selectedOffer.minOrder}, Max: ${selectedOffer.maxOrder}`}
value={tradeAmount}
onChange={(e) => setTradeAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="bg-gray-800 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Price per {selectedOffer.token}</span>
<span className="text-white">${selectedOffer.price}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Total Amount</span>
<span className="text-white font-semibold">
${(parseFloat(tradeAmount || '0') * selectedOffer.price).toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Payment Method</span>
<span className="text-white">{selectedOffer.paymentMethod}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Time Limit</span>
<span className="text-white">{selectedOffer.timeLimit} minutes</span>
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => handleTrade(selectedOffer)}
>
Confirm {activeTab === 'buy' ? 'Purchase' : 'Sale'}
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => setSelectedOffer(null)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Create Order Modal (Binance P2P style) */}
{showCreateOrder && (
<Card className="bg-gray-900 border-gray-800 fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Create P2P Order</CardTitle>
<Button variant="ghost" size="icon" onClick={() => setShowCreateOrder(false)}>
<X className="w-4 h-4" />
</Button>
</div>
<CardDescription>
Create a {activeTab === 'buy' ? 'buy' : 'sell'} order for {selectedToken}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Order Type</Label>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'buy' | 'sell')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="buy">Buy</TabsTrigger>
<TabsTrigger value="sell">Sell</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<Label>Token</Label>
<Select value={selectedToken} onValueChange={(v) => setSelectedToken(v as 'HEZ' | 'PEZ')}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Amount ({selectedToken})</Label>
<Input
type="number"
placeholder="Enter amount"
value={newOrderAmount}
onChange={(e) => setNewOrderAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div>
<Label>Price per {selectedToken} ($)</Label>
<Input
type="number"
placeholder="Enter price"
value={newOrderPrice}
onChange={(e) => setNewOrderPrice(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div>
<Label>Payment Method</Label>
<Select value={newOrderPaymentMethod} onValueChange={setNewOrderPaymentMethod}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentMethods.map(method => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-gray-800 p-3 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Total Value</span>
<span className="text-white font-semibold">
${(parseFloat(newOrderAmount || '0') * parseFloat(newOrderPrice || '0')).toFixed(2)}
</span>
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => {
console.log('Creating order:', {
type: activeTab,
token: selectedToken,
amount: newOrderAmount,
price: newOrderPrice,
paymentMethod: newOrderPaymentMethod
});
// TODO: Implement blockchain integration
setShowCreateOrder(false);
}}
>
Create Order
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => setShowCreateOrder(false)}
>
Cancel
</Button>
</div>
<div className="text-xs text-gray-500 text-center">
Note: Blockchain integration for P2P orders is coming soon
</div>
</CardContent>
</Card>
)}
{/* Escrow Modal (Binance P2P Escrow style) */}
{showEscrow && escrowOffer && (
<Card className="bg-gray-900 border-gray-800 fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-2xl">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-blue-400" />
Secure Escrow Trade
</CardTitle>
<Button variant="ghost" size="icon" onClick={() => setShowEscrow(false)}>
<X className="w-4 h-4" />
</Button>
</div>
<CardDescription>
Trade safely with escrow protection {activeTab === 'buy' ? 'Buying' : 'Selling'} {escrowOffer.token}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Escrow Steps Indicator */}
<div className="flex justify-between items-center">
{[
{ step: 'funding', label: 'Fund Escrow', icon: Lock },
{ step: 'confirmation', label: 'Payment', icon: Clock },
{ step: 'release', label: 'Complete', icon: CheckCircle }
].map((item, idx) => (
<div key={item.step} className="flex-1 flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
escrowStep === item.step ? 'bg-blue-600' :
['funding', 'confirmation', 'release'].indexOf(escrowStep) > idx ? 'bg-green-600' : 'bg-gray-700'
}`}>
<item.icon className="w-5 h-5 text-white" />
</div>
<span className="text-xs text-gray-400 mt-2">{item.label}</span>
{idx < 2 && (
<div className={`absolute w-32 h-0.5 mt-5 ${
['funding', 'confirmation', 'release'].indexOf(escrowStep) > idx ? 'bg-green-600' : 'bg-gray-700'
}`} style={{ left: `calc(${(idx + 1) * 33.33}% - 64px)` }}></div>
)}
</div>
))}
</div>
{/* Trade Details Card */}
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-3">Trade Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Seller</span>
<span className="text-white font-semibold">{escrowOffer.seller.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Amount</span>
<span className="text-white font-semibold">{tradeAmount} {escrowOffer.token}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Price per {escrowOffer.token}</span>
<span className="text-white font-semibold">${escrowOffer.price}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Payment Method</span>
<span className="text-white font-semibold">{escrowOffer.paymentMethod}</span>
</div>
<div className="flex justify-between pt-2 border-t border-gray-700">
<span className="text-gray-400">Total</span>
<span className="text-lg font-bold text-white">
${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}
</span>
</div>
</div>
</Card>
{/* Step Content */}
{escrowStep === 'funding' && (
<div className="space-y-4">
<div className="bg-blue-900/20 border border-blue-500/30 rounded-lg p-4">
<div className="flex gap-3">
<Shield className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-200">
<strong>Escrow Protection:</strong> Your funds will be held securely in smart contract escrow until both parties confirm the trade. This protects both buyer and seller.
</div>
</div>
</div>
<div className="text-sm text-gray-400">
1. Fund the escrow with {tradeAmount} {escrowOffer.token}<br />
2. Wait for seller to provide payment details<br />
3. Complete payment via {escrowOffer.paymentMethod}<br />
4. Confirm payment to release escrow
</div>
<Button
onClick={handleEscrowFund}
className="w-full bg-blue-600 hover:bg-blue-700"
>
Fund Escrow ({tradeAmount} {escrowOffer.token})
</Button>
</div>
)}
{escrowStep === 'confirmation' && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-500/30 rounded-lg p-4">
<div className="flex gap-3">
<Clock className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-200">
<strong>Waiting for Payment:</strong> Complete your {escrowOffer.paymentMethod} payment and click confirm when done. Do not release escrow until payment is verified!
</div>
</div>
</div>
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-2">Payment Instructions</h4>
<div className="text-sm text-gray-300 space-y-1">
<p> Payment Method: {escrowOffer.paymentMethod}</p>
<p> Amount: ${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}</p>
<p> Time Limit: {escrowOffer.timeLimit} minutes</p>
</div>
</Card>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
setShowEscrow(false);
setEscrowStep('funding');
}}
className="flex-1"
>
Cancel Trade
</Button>
<Button
onClick={handleEscrowConfirm}
className="flex-1 bg-green-600 hover:bg-green-700"
>
I've Made Payment
</Button>
</div>
</div>
)}
{escrowStep === 'release' && (
<div className="space-y-4">
<div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4">
<div className="flex gap-3">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-green-200">
<strong>Payment Confirmed:</strong> Your payment has been verified. The escrow will be released to the seller automatically.
</div>
</div>
</div>
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-2">Trade Summary</h4>
<div className="text-sm text-gray-300 space-y-1">
<p>✅ Escrow Funded: {tradeAmount} {escrowOffer.token}</p>
<p>✅ Payment Sent: ${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}</p>
<p> Payment Verified</p>
<p className="text-green-400 font-semibold mt-2">🎉 Trade Completed Successfully!</p>
</div>
</Card>
<Button
onClick={handleEscrowRelease}
className="w-full bg-green-600 hover:bg-green-700"
>
Close & Release Escrow
</Button>
</div>
)}
<div className="text-xs text-gray-500 text-center">
Note: Smart contract escrow integration coming soon
</div>
</CardContent>
</Card>
)}
{/* Overlay */}
{(showCreateOrder || selectedOffer || showEscrow) && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40" onClick={() => {
setShowCreateOrder(false);
setSelectedOffer(null);
setShowEscrow(false);
}}></div>
)}
</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 { FileText, DollarSign, Code, Users, ChevronRight, ChevronLeft, Check } from 'lucide-react';
interface ProposalWizardProps {
onComplete: (proposal: any) => 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 PZK"
/>
</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} PZK</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,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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { toast } = useToast();
useEffect(() => {
loadRoles();
}, []);
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 (error) {
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 (error) {
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, TrendingUp, Users, Key, Activity } from 'lucide-react';
import { LineChart, Line, AreaChart, Area, BarChart, Bar, 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, setLoading] = 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) {
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, AlertTriangle, 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 (error) {
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 (error) {
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,714 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, 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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, Coins, Lock, Clock, Award, AlertCircle, CheckCircle2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { toast } from '@/components/ui/use-toast';
import { web3FromAddress } from '@polkadot/extension-dapp';
import {
getStakingInfo,
getActiveValidators,
getMinNominatorBond,
getBondingDuration,
getCurrentEra,
parseAmount,
type StakingInfo
} from '@/lib/staking';
export const StakingDashboard: React.FC = () => {
const { t } = useTranslation();
const { api, selectedAccount, isApiReady } = usePolkadot();
const { balances, refreshBalances } = useWallet();
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
const [validators, setValidators] = useState<string[]>([]);
const [minNominatorBond, setMinNominatorBond] = useState('0');
const [bondingDuration, setBondingDuration] = useState(28);
const [currentEra, setCurrentEra] = useState(0);
const [bondAmount, setBondAmount] = useState('');
const [unbondAmount, setUnbondAmount] = useState('');
const [selectedValidators, setSelectedValidators] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(false);
// Fetch staking data
useEffect(() => {
const fetchStakingData = async () => {
if (!api || !isApiReady || !selectedAccount) {
return;
}
setIsLoadingData(true);
try {
const [info, activeVals, minBond, duration, era] = await Promise.all([
getStakingInfo(api, selectedAccount.address),
getActiveValidators(api),
getMinNominatorBond(api),
getBondingDuration(api),
getCurrentEra(api)
]);
setStakingInfo(info);
setValidators(activeVals);
setMinNominatorBond(minBond);
setBondingDuration(duration);
setCurrentEra(era);
// Pre-select current nominations if any
if (info.nominations.length > 0) {
setSelectedValidators(info.nominations);
}
} catch (error) {
console.error('Failed to fetch staking data:', error);
toast({
title: 'Error',
description: 'Failed to fetch staking information',
variant: 'destructive',
});
} finally {
setIsLoadingData(false);
}
};
fetchStakingData();
const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s
return () => clearInterval(interval);
}, [api, isApiReady, selectedAccount]);
const handleBond = async () => {
if (!api || !selectedAccount || !bondAmount) return;
setIsLoading(true);
try {
const amount = parseAmount(bondAmount);
// Validate
if (parseFloat(bondAmount) < parseFloat(minNominatorBond)) {
throw new Error(`Minimum bond is ${minNominatorBond} HEZ`);
}
if (parseFloat(bondAmount) > parseFloat(balances.HEZ)) {
throw new Error('Insufficient HEZ balance');
}
const injector = await web3FromAddress(selectedAccount.address);
// If already bonded, use bondExtra, otherwise use bond
let tx;
if (stakingInfo && parseFloat(stakingInfo.bonded) > 0) {
tx = api.tx.staking.bondExtra(amount);
} else {
// For new bond, also need to specify reward destination
tx = api.tx.staking.bond(amount, 'Staked'); // Auto-compound rewards
}
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, events, dispatchError }) => {
if (status.isInBlock) {
console.log('Transaction in block:', status.asInBlock.toHex());
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Bonded ${bondAmount} HEZ successfully`,
});
setBondAmount('');
refreshBalances();
// Refresh staking data after a delay
setTimeout(() => {
if (api && selectedAccount) {
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
}
}, 3000);
setIsLoading(false);
}
}
}
);
} catch (error: any) {
console.error('Bond failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to bond tokens',
variant: 'destructive',
});
setIsLoading(false);
}
};
const handleNominate = async () => {
if (!api || !selectedAccount || selectedValidators.length === 0) return;
if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) {
toast({
title: 'Error',
description: 'You must bond tokens before nominating validators',
variant: 'destructive',
});
return;
}
setIsLoading(true);
try {
const injector = await web3FromAddress(selectedAccount.address);
const tx = api.tx.staking.nominate(selectedValidators);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Nomination failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Nominated ${selectedValidators.length} validator(s)`,
});
// Refresh staking data
setTimeout(() => {
if (api && selectedAccount) {
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
}
}, 3000);
setIsLoading(false);
}
}
}
);
} catch (error: any) {
console.error('Nomination failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to nominate validators',
variant: 'destructive',
});
setIsLoading(false);
}
};
const handleUnbond = async () => {
if (!api || !selectedAccount || !unbondAmount) return;
setIsLoading(true);
try {
const amount = parseAmount(unbondAmount);
if (!stakingInfo || parseFloat(unbondAmount) > parseFloat(stakingInfo.active)) {
throw new Error('Insufficient staked amount');
}
const injector = await web3FromAddress(selectedAccount.address);
const tx = api.tx.staking.unbond(amount);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Unbond failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Unbonded ${unbondAmount} HEZ. Withdrawal available in ${bondingDuration} eras`,
});
setUnbondAmount('');
setTimeout(() => {
if (api && selectedAccount) {
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
}
}, 3000);
setIsLoading(false);
}
}
}
);
} catch (error: any) {
console.error('Unbond failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to unbond tokens',
variant: 'destructive',
});
setIsLoading(false);
}
};
const handleWithdrawUnbonded = async () => {
if (!api || !selectedAccount) return;
if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) {
toast({
title: 'Info',
description: 'No tokens available to withdraw',
});
return;
}
setIsLoading(true);
try {
const injector = await web3FromAddress(selectedAccount.address);
// Number of slashing spans (usually 0)
const tx = api.tx.staking.withdrawUnbonded(0);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Withdrawal failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Withdrew ${stakingInfo.redeemable} HEZ`,
});
refreshBalances();
setTimeout(() => {
if (api && selectedAccount) {
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
}
}, 3000);
setIsLoading(false);
}
}
}
);
} catch (error: any) {
console.error('Withdrawal failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to withdraw tokens',
variant: 'destructive',
});
setIsLoading(false);
}
};
const handleStartScoreTracking = async () => {
if (!api || !selectedAccount) return;
if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) {
toast({
title: 'Error',
description: 'You must bond tokens before starting score tracking',
variant: 'destructive',
});
return;
}
setIsLoading(true);
try {
const injector = await web3FromAddress(selectedAccount.address);
const tx = api.tx.stakingScore.startScoreTracking();
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Failed to start score tracking';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
setIsLoading(false);
} else {
toast({
title: 'Success',
description: 'Score tracking started successfully! Your staking score will now accumulate over time.',
});
// Refresh staking data after a delay
setTimeout(() => {
if (api && selectedAccount) {
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
}
}, 3000);
setIsLoading(false);
}
}
}
);
} catch (error: any) {
console.error('Start score tracking failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to start score tracking',
variant: 'destructive',
});
setIsLoading(false);
}
};
const toggleValidator = (validator: string) => {
setSelectedValidators(prev => {
if (prev.includes(validator)) {
return prev.filter(v => v !== validator);
} else {
// Max 16 nominations
if (prev.length >= 16) {
toast({
title: 'Limit Reached',
description: 'Maximum 16 validators can be nominated',
});
return prev;
}
return [...prev, validator];
}
});
};
if (isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400">Loading staking data...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Total Bonded</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{stakingInfo?.bonded || '0'} HEZ
</div>
<p className="text-xs text-gray-500 mt-1">
Active: {stakingInfo?.active || '0'} HEZ
</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Unlocking</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">
{stakingInfo?.unlocking.reduce((sum, u) => sum + parseFloat(u.amount), 0).toFixed(2) || '0'} HEZ
</div>
<p className="text-xs text-gray-500 mt-1">
{stakingInfo?.unlocking.length || 0} chunk(s)
</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Redeemable</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">
{stakingInfo?.redeemable || '0'} HEZ
</div>
<Button
size="sm"
onClick={handleWithdrawUnbonded}
disabled={!stakingInfo || parseFloat(stakingInfo.redeemable) === 0 || isLoading}
className="mt-2 w-full"
>
Withdraw
</Button>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Staking Score</CardTitle>
</CardHeader>
<CardContent>
{stakingInfo?.hasStartedScoreTracking ? (
<>
<div className="text-2xl font-bold text-purple-500">
{stakingInfo.stakingScore}/100
</div>
<p className="text-xs text-gray-500 mt-1">
Duration: {stakingInfo.stakingDuration
? `${Math.floor(stakingInfo.stakingDuration / (24 * 60 * 10))} days`
: '0 days'}
</p>
</>
) : (
<>
<div className="text-2xl font-bold text-gray-500">Not Started</div>
<Button
size="sm"
onClick={handleStartScoreTracking}
disabled={!stakingInfo || parseFloat(stakingInfo.bonded) === 0 || isLoading}
className="mt-2 w-full bg-purple-600 hover:bg-purple-700"
>
Start Score Tracking
</Button>
</>
)}
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">PEZ Rewards</CardTitle>
</CardHeader>
<CardContent>
{stakingInfo?.pezRewards && stakingInfo.pezRewards.hasPendingClaim ? (
<>
<div className="text-2xl font-bold text-orange-500">
{parseFloat(stakingInfo.pezRewards.totalClaimable).toFixed(2)} PEZ
</div>
<p className="text-xs text-gray-500 mt-1">
{stakingInfo.pezRewards.claimableRewards.length} epoch(s) to claim
</p>
<Button
size="sm"
onClick={() => {
toast({
title: 'Coming Soon',
description: 'Claim PEZ rewards functionality will be available soon',
});
}}
disabled={isLoading}
className="mt-2 w-full bg-orange-600 hover:bg-orange-700"
>
Claim Rewards
</Button>
</>
) : (
<>
<div className="text-2xl font-bold text-gray-500">0 PEZ</div>
<p className="text-xs text-gray-500 mt-1">
{stakingInfo?.pezRewards
? `Epoch ${stakingInfo.pezRewards.currentEpoch}`
: 'No rewards available'}
</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Main Staking Interface */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-xl text-white">Validator Nomination Staking</CardTitle>
<CardDescription className="text-gray-400">
Bond HEZ and nominate validators to earn staking rewards
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="stake">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="stake">Stake</TabsTrigger>
<TabsTrigger value="nominate">Nominate</TabsTrigger>
<TabsTrigger value="unstake">Unstake</TabsTrigger>
</TabsList>
{/* STAKE TAB */}
<TabsContent value="stake" className="space-y-4">
<Alert className="bg-blue-900/20 border-blue-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Minimum bond: {minNominatorBond} HEZ. Bonded tokens are locked and earn rewards when nominated validators produce blocks.
</AlertDescription>
</Alert>
<div>
<Label>Amount to Bond (HEZ)</Label>
<Input
type="number"
placeholder={`Min: ${minNominatorBond}`}
value={bondAmount}
onChange={(e) => setBondAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
disabled={isLoading}
/>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Available: {balances.HEZ} HEZ</span>
<button
onClick={() => setBondAmount(balances.HEZ)}
className="text-blue-400 hover:text-blue-300"
>
Max
</button>
</div>
</div>
<Button
onClick={handleBond}
disabled={isLoading || !bondAmount || parseFloat(bondAmount) < parseFloat(minNominatorBond)}
className="w-full bg-green-600 hover:bg-green-700"
>
{stakingInfo && parseFloat(stakingInfo.bonded) > 0 ? 'Bond Additional' : 'Bond Tokens'}
</Button>
</TabsContent>
{/* NOMINATE TAB */}
<TabsContent value="nominate" className="space-y-4">
<Alert className="bg-purple-900/20 border-purple-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Select up to 16 validators to nominate. Your stake will be distributed to active validators.
{stakingInfo && parseFloat(stakingInfo.bonded) === 0 && ' You must bond tokens first.'}
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>Active Validators ({validators.length})</Label>
<div className="max-h-64 overflow-y-auto space-y-2 border border-gray-700 rounded-lg p-3 bg-gray-800">
{validators.map((validator) => (
<div
key={validator}
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
selectedValidators.includes(validator)
? 'bg-purple-900/30 border border-purple-500'
: 'bg-gray-700 hover:bg-gray-600'
}`}
onClick={() => toggleValidator(validator)}
>
<span className="text-sm font-mono truncate flex-1">
{validator.slice(0, 8)}...{validator.slice(-8)}
</span>
{selectedValidators.includes(validator) && (
<CheckCircle2 className="w-4 h-4 text-purple-400 ml-2" />
)}
</div>
))}
</div>
<p className="text-xs text-gray-400">
Selected: {selectedValidators.length}/16
</p>
</div>
<Button
onClick={handleNominate}
disabled={isLoading || selectedValidators.length === 0 || !stakingInfo || parseFloat(stakingInfo.bonded) === 0}
className="w-full bg-purple-600 hover:bg-purple-700"
>
Nominate Validators
</Button>
</TabsContent>
{/* UNSTAKE TAB */}
<TabsContent value="unstake" className="space-y-4">
<Alert className="bg-yellow-900/20 border-yellow-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Unbonded tokens will be locked for {bondingDuration} eras (~{Math.floor(bondingDuration / 4)} days) before withdrawal.
</AlertDescription>
</Alert>
<div>
<Label>Amount to Unbond (HEZ)</Label>
<Input
type="number"
placeholder={`Max: ${stakingInfo?.active || '0'}`}
value={unbondAmount}
onChange={(e) => setUnbondAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
disabled={isLoading}
/>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Staked: {stakingInfo?.active || '0'} HEZ</span>
<button
onClick={() => setUnbondAmount(stakingInfo?.active || '0')}
className="text-blue-400 hover:text-blue-300"
>
Max
</button>
</div>
</div>
{stakingInfo && stakingInfo.unlocking.length > 0 && (
<div className="bg-gray-800 rounded-lg p-3 space-y-2">
<Label className="text-sm">Unlocking Chunks</Label>
{stakingInfo.unlocking.map((chunk, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-gray-400">{chunk.amount} HEZ</span>
<span className="text-gray-500">
Era {chunk.era} ({chunk.blocksRemaining > 0 ? `~${Math.floor(chunk.blocksRemaining / 600)} blocks` : 'Ready'})
</span>
</div>
))}
</div>
)}
<Button
onClick={handleUnbond}
disabled={isLoading || !unbondAmount || !stakingInfo || parseFloat(stakingInfo.active) === 0}
className="w-full bg-red-600 hover:bg-red-700"
variant="destructive"
>
Unbond Tokens
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
};
+69
View File
@@ -0,0 +1,69 @@
"use client"
import * as React from "react"
import { createContext, useContext, useEffect, useState } from "react"
import { ThemeProviderProps } from "next-themes/dist/types"
type Theme = "dark" | "light" | "system"
type ThemeContextType = {
theme: Theme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export function ThemeProvider({
children,
defaultTheme = "system",
value: _value,
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== "undefined") {
const savedTheme = localStorage.getItem("theme")
return (savedTheme && (savedTheme === "dark" || savedTheme === "light" || savedTheme === "system")
? savedTheme
: defaultTheme) as Theme
}
return defaultTheme as Theme
})
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value: ThemeContextType = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem("theme", theme)
setTheme(theme)
},
}
return (
<ThemeContext.Provider value={value} {...props}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
+306
View File
@@ -0,0 +1,306 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { X, Clock, CheckCircle, AlertCircle } from 'lucide-react';
interface LimitOrder {
id: string;
type: 'buy' | 'sell';
fromToken: string;
toToken: string;
fromAmount: number;
limitPrice: number;
currentPrice: number;
status: 'pending' | 'filled' | 'cancelled' | 'expired';
createdAt: number;
expiresAt: number;
}
interface LimitOrdersProps {
fromToken: string;
toToken: string;
currentPrice: number;
onCreateOrder?: (order: Omit<LimitOrder, 'id' | 'status' | 'createdAt' | 'expiresAt'>) => void;
}
export const LimitOrders: React.FC<LimitOrdersProps> = ({
fromToken,
toToken,
currentPrice,
onCreateOrder
}) => {
const [orderType, setOrderType] = useState<'buy' | 'sell'>('buy');
const [amount, setAmount] = useState('');
const [limitPrice, setLimitPrice] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
// Mock orders (in production, fetch from blockchain)
const [orders, setOrders] = useState<LimitOrder[]>([
{
id: '1',
type: 'buy',
fromToken: 'PEZ',
toToken: 'HEZ',
fromAmount: 100,
limitPrice: 0.98,
currentPrice: 1.02,
status: 'pending',
createdAt: Date.now() - 3600000,
expiresAt: Date.now() + 82800000
},
{
id: '2',
type: 'sell',
fromToken: 'HEZ',
toToken: 'PEZ',
fromAmount: 50,
limitPrice: 1.05,
currentPrice: 1.02,
status: 'pending',
createdAt: Date.now() - 7200000,
expiresAt: Date.now() + 79200000
}
]);
const handleCreateOrder = () => {
const newOrder: Omit<LimitOrder, 'id' | 'status' | 'createdAt' | 'expiresAt'> = {
type: orderType,
fromToken: orderType === 'buy' ? toToken : fromToken,
toToken: orderType === 'buy' ? fromToken : toToken,
fromAmount: parseFloat(amount),
limitPrice: parseFloat(limitPrice),
currentPrice
};
console.log('Creating limit order:', newOrder);
// Add to orders list (mock)
const order: LimitOrder = {
...newOrder,
id: Date.now().toString(),
status: 'pending',
createdAt: Date.now(),
expiresAt: Date.now() + 86400000 // 24 hours
};
setOrders([order, ...orders]);
setShowCreateForm(false);
setAmount('');
setLimitPrice('');
if (onCreateOrder) {
onCreateOrder(newOrder);
}
};
const handleCancelOrder = (orderId: string) => {
setOrders(orders.map(order =>
order.id === orderId ? { ...order, status: 'cancelled' as const } : order
));
};
const getStatusBadge = (status: LimitOrder['status']) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending
</Badge>;
case 'filled':
return <Badge variant="outline" className="bg-green-500/10 text-green-400 border-green-500/30">
<CheckCircle className="w-3 h-3 mr-1" />
Filled
</Badge>;
case 'cancelled':
return <Badge variant="outline" className="bg-gray-500/10 text-gray-400 border-gray-500/30">
<X className="w-3 h-3 mr-1" />
Cancelled
</Badge>;
case 'expired':
return <Badge variant="outline" className="bg-red-500/10 text-red-400 border-red-500/30">
<AlertCircle className="w-3 h-3 mr-1" />
Expired
</Badge>;
}
};
const getPriceDistance = (order: LimitOrder) => {
const distance = ((order.limitPrice - order.currentPrice) / order.currentPrice) * 100;
return distance;
};
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>Limit Orders</CardTitle>
<CardDescription>
Set orders to execute at your target price
</CardDescription>
</div>
<Button
onClick={() => setShowCreateForm(!showCreateForm)}
className="bg-blue-600 hover:bg-blue-700"
>
{showCreateForm ? 'Cancel' : '+ New Order'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{showCreateForm && (
<Card className="bg-gray-800 border-gray-700 p-4">
<div className="space-y-4">
<div>
<Label>Order Type</Label>
<Tabs value={orderType} onValueChange={(v) => setOrderType(v as 'buy' | 'sell')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="buy">Buy {fromToken}</TabsTrigger>
<TabsTrigger value="sell">Sell {fromToken}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<Label>Amount ({orderType === 'buy' ? toToken : fromToken})</Label>
<Input
type="number"
placeholder="0.0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
<div>
<Label>Limit Price (1 {fromToken} = ? {toToken})</Label>
<Input
type="number"
placeholder="0.0"
value={limitPrice}
onChange={(e) => setLimitPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
<div className="text-xs text-gray-500 mt-1">
Current market price: ${currentPrice.toFixed(4)}
</div>
</div>
<div className="bg-gray-900 p-3 rounded-lg space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-400">You will {orderType}</span>
<span className="text-white font-semibold">
{amount || '0'} {orderType === 'buy' ? fromToken : toToken}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">When price reaches</span>
<span className="text-white font-semibold">
${limitPrice || '0'} per {fromToken}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Estimated total</span>
<span className="text-white font-semibold">
{((parseFloat(amount || '0') * parseFloat(limitPrice || '0'))).toFixed(2)} {orderType === 'buy' ? toToken : fromToken}
</span>
</div>
</div>
<Button
onClick={handleCreateOrder}
disabled={!amount || !limitPrice}
className="w-full bg-green-600 hover:bg-green-700"
>
Create Limit Order
</Button>
<div className="text-xs text-gray-500 text-center">
Order will expire in 24 hours if not filled
</div>
</div>
</Card>
)}
{/* Orders List */}
<div className="space-y-3">
{orders.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No limit orders yet. Create one to get started!
</div>
) : (
orders.map(order => {
const priceDistance = getPriceDistance(order);
return (
<Card key={order.id} className="bg-gray-800 border-gray-700 p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Badge variant={order.type === 'buy' ? 'default' : 'secondary'}>
{order.type.toUpperCase()}
</Badge>
<span className="font-semibold text-white">
{order.fromToken} {order.toToken}
</span>
</div>
{getStatusBadge(order.status)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mb-3">
<div>
<div className="text-gray-400">Amount</div>
<div className="text-white font-semibold">
{order.fromAmount} {order.fromToken}
</div>
</div>
<div>
<div className="text-gray-400">Limit Price</div>
<div className="text-white font-semibold">
${order.limitPrice.toFixed(4)}
</div>
</div>
<div>
<div className="text-gray-400">Current Price</div>
<div className="text-white">
${order.currentPrice.toFixed(4)}
</div>
</div>
<div>
<div className="text-gray-400">Distance</div>
<div className={priceDistance > 0 ? 'text-green-400' : 'text-red-400'}>
{priceDistance > 0 ? '+' : ''}{priceDistance.toFixed(2)}%
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
Created {new Date(order.createdAt).toLocaleString()}
</span>
{order.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelOrder(order.id)}
className="h-7 text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
Cancel
</Button>
)}
</div>
</Card>
);
})
)}
</div>
<div className="text-xs text-gray-500 text-center pt-2">
Note: Limit orders require blockchain integration to execute automatically
</div>
</CardContent>
</Card>
);
};
+167
View File
@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import { Card } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TrendingUp, TrendingDown } from 'lucide-react';
interface PriceChartProps {
fromToken: string;
toToken: string;
currentPrice: number;
}
// Helper: Convert backend token symbols to user-facing display names
const getDisplayName = (token: string): string => {
if (token === 'wUSDT') return 'USDT';
if (token === 'wHEZ') return 'HEZ';
return token; // HEZ, PEZ, etc. remain the same
};
export const PriceChart: React.FC<PriceChartProps> = ({ fromToken, toToken, currentPrice }) => {
const [timeframe, setTimeframe] = useState<'1H' | '24H' | '7D' | '30D'>('24H');
const [chartData, setChartData] = useState<any[]>([]);
const [priceChange, setPriceChange] = useState<{ value: number; percent: number }>({ value: 0, percent: 0 });
useEffect(() => {
// Generate mock historical data (in production, fetch from blockchain/oracle)
const generateMockData = () => {
const dataPoints = timeframe === '1H' ? 60 : timeframe === '24H' ? 24 : timeframe === '7D' ? 7 : 30;
const basePrice = currentPrice || 1.0;
const data = [];
let price = basePrice * 0.95; // Start 5% below current
for (let i = 0; i < dataPoints; i++) {
// Random walk with slight upward trend
const change = (Math.random() - 0.48) * 0.02; // Slight bullish bias
price = price * (1 + change);
let timeLabel = '';
const now = new Date();
if (timeframe === '1H') {
now.setMinutes(now.getMinutes() - (dataPoints - i));
timeLabel = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
} else if (timeframe === '24H') {
now.setHours(now.getHours() - (dataPoints - i));
timeLabel = now.toLocaleTimeString('en-US', { hour: '2-digit' });
} else if (timeframe === '7D') {
now.setDate(now.getDate() - (dataPoints - i));
timeLabel = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} else {
now.setDate(now.getDate() - (dataPoints - i));
timeLabel = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
data.push({
time: timeLabel,
price: parseFloat(price.toFixed(4)),
timestamp: now.getTime()
});
}
// Add current price as last point
data.push({
time: 'Now',
price: basePrice,
timestamp: Date.now()
});
return data;
};
const data = generateMockData();
setChartData(data);
// Calculate price change
if (data.length > 1) {
const firstPrice = data[0].price;
const lastPrice = data[data.length - 1].price;
const change = lastPrice - firstPrice;
const changePercent = (change / firstPrice) * 100;
setPriceChange({ value: change, percent: changePercent });
}
}, [timeframe, currentPrice]);
const isPositive = priceChange.percent >= 0;
return (
<Card className="p-4 bg-gray-900 border-gray-800">
<div className="flex justify-between items-center mb-4">
<div>
<div className="text-sm text-gray-400 mb-1">
{getDisplayName(fromToken)}/{getDisplayName(toToken)} Price
</div>
<div className="flex items-center gap-3">
<span className="text-2xl font-bold text-white">
${currentPrice.toFixed(4)}
</span>
<div className={`flex items-center gap-1 text-sm font-semibold ${
isPositive ? 'text-green-400' : 'text-red-400'
}`}>
{isPositive ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isPositive ? '+' : ''}{priceChange.percent.toFixed(2)}%
</div>
</div>
</div>
<Tabs value={timeframe} onValueChange={(v) => setTimeframe(v as any)}>
<TabsList className="bg-gray-800">
<TabsTrigger value="1H" className="text-xs">1H</TabsTrigger>
<TabsTrigger value="24H" className="text-xs">24H</TabsTrigger>
<TabsTrigger value="7D" className="text-xs">7D</TabsTrigger>
<TabsTrigger value="30D" className="text-xs">30D</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`gradient-${isPositive ? 'green' : 'red'}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={isPositive ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
<stop offset="100%" stopColor={isPositive ? '#10b981' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="time"
stroke="#6b7280"
fontSize={10}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#6b7280"
fontSize={10}
tickLine={false}
axisLine={false}
domain={['auto', 'auto']}
tickFormatter={(value) => `$${value.toFixed(3)}`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '8px',
padding: '8px'
}}
labelStyle={{ color: '#9ca3af' }}
itemStyle={{ color: '#fff' }}
formatter={(value: any) => [`$${value.toFixed(4)}`, 'Price']}
/>
<Area
type="monotone"
dataKey="price"
stroke={isPositive ? '#10b981' : '#ef4444'}
strokeWidth={2}
fill={`url(#gradient-${isPositive ? 'green' : 'red'})`}
/>
</AreaChart>
</ResponsiveContainer>
<div className="mt-3 text-xs text-gray-500 text-center">
Historical price data Updated in real-time
</div>
</Card>
);
};
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, 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 { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
Calculator,
FileText,
Users,
Calendar,
DollarSign,
AlertCircle
} from 'lucide-react';
interface BudgetItem {
id: string;
description: string;
amount: number;
category: string;
justification: string;
}
interface Milestone {
id: string;
title: string;
deliverables: string;
amount: number;
deadline: string;
}
export const FundingProposal: React.FC = () => {
const { t } = useTranslation();
const [proposalTitle, setProposalTitle] = useState('');
const [proposalDescription, setProposalDescription] = useState('');
const [category, setCategory] = useState('');
const [budgetItems, setBudgetItems] = useState<BudgetItem[]>([
{ id: '1', description: '', amount: 0, category: '', justification: '' }
]);
const [milestones, setMilestones] = useState<Milestone[]>([
{ id: '1', title: '', deliverables: '', amount: 0, deadline: '' }
]);
const addBudgetItem = () => {
setBudgetItems([...budgetItems, {
id: Date.now().toString(),
description: '',
amount: 0,
category: '',
justification: ''
}]);
};
const removeBudgetItem = (id: string) => {
setBudgetItems(budgetItems.filter(item => item.id !== id));
};
const updateBudgetItem = (id: string, field: keyof BudgetItem, value: any) => {
setBudgetItems(budgetItems.map(item =>
item.id === id ? { ...item, [field]: value } : item
));
};
const addMilestone = () => {
setMilestones([...milestones, {
id: Date.now().toString(),
title: '',
deliverables: '',
amount: 0,
deadline: ''
}]);
};
const removeMilestone = (id: string) => {
setMilestones(milestones.filter(m => m.id !== id));
};
const updateMilestone = (id: string, field: keyof Milestone, value: any) => {
setMilestones(milestones.map(m =>
m.id === id ? { ...m, [field]: value } : m
));
};
const totalBudget = budgetItems.reduce((sum, item) => sum + (item.amount || 0), 0);
const totalMilestoneAmount = milestones.reduce((sum, m) => sum + (m.amount || 0), 0);
return (
<div className="space-y-6">
{/* Proposal Header */}
<Card>
<CardHeader>
<CardTitle>Create Funding Proposal</CardTitle>
<CardDescription>Submit a detailed budget request for treasury funding</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Proposal Title</Label>
<Input
id="title"
placeholder="Enter a clear, descriptive title"
value={proposalTitle}
onChange={(e) => setProposalTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="development">Development</SelectItem>
<SelectItem value="marketing">Marketing</SelectItem>
<SelectItem value="operations">Operations</SelectItem>
<SelectItem value="community">Community</SelectItem>
<SelectItem value="research">Research</SelectItem>
<SelectItem value="infrastructure">Infrastructure</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Provide a detailed description of the proposal"
rows={4}
value={proposalDescription}
onChange={(e) => setProposalDescription(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Budget Items */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Budget Breakdown</span>
<Badge variant="outline" className="text-lg px-3 py-1">
Total: ${totalBudget.toLocaleString()}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{budgetItems.map((item, index) => (
<div key={item.id} className="p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">Item {index + 1}</span>
{budgetItems.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeBudgetItem(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="Budget item description"
value={item.description}
onChange={(e) => updateBudgetItem(item.id, 'description', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Amount ($)</Label>
<Input
type="number"
placeholder="0"
value={item.amount || ''}
onChange={(e) => updateBudgetItem(item.id, 'amount', parseFloat(e.target.value) || 0)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Justification</Label>
<Textarea
placeholder="Explain why this expense is necessary"
rows={2}
value={item.justification}
onChange={(e) => updateBudgetItem(item.id, 'justification', e.target.value)}
/>
</div>
</div>
))}
<Button onClick={addBudgetItem} variant="outline" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Budget Item
</Button>
</CardContent>
</Card>
{/* Milestones */}
<Card>
<CardHeader>
<CardTitle>Milestones & Deliverables</CardTitle>
<CardDescription>Define clear milestones with payment schedule</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{milestones.map((milestone, index) => (
<div key={milestone.id} className="p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">Milestone {index + 1}</span>
{milestones.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeMilestone(milestone.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Title</Label>
<Input
placeholder="Milestone title"
value={milestone.title}
onChange={(e) => updateMilestone(milestone.id, 'title', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Payment Amount ($)</Label>
<Input
type="number"
placeholder="0"
value={milestone.amount || ''}
onChange={(e) => updateMilestone(milestone.id, 'amount', parseFloat(e.target.value) || 0)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Deliverables</Label>
<Textarea
placeholder="What will be delivered"
rows={2}
value={milestone.deliverables}
onChange={(e) => updateMilestone(milestone.id, 'deliverables', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Deadline</Label>
<Input
type="date"
value={milestone.deadline}
onChange={(e) => updateMilestone(milestone.id, 'deadline', e.target.value)}
/>
</div>
</div>
</div>
))}
<Button onClick={addMilestone} variant="outline" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Milestone
</Button>
{totalMilestoneAmount !== totalBudget && totalMilestoneAmount > 0 && (
<div className="flex items-center gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-gray-900">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-sm text-gray-900">
Milestone total (${totalMilestoneAmount.toLocaleString()}) doesn't match budget total (${totalBudget.toLocaleString()})
</span>
</div>
)}
</CardContent>
</Card>
{/* Submit Button */}
<div className="flex justify-end gap-3">
<Button variant="outline">Save Draft</Button>
<Button>Submit Proposal</Button>
</div>
</div>
);
};
@@ -0,0 +1,278 @@
import React, { useState } 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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import {
Shield,
CheckCircle,
XCircle,
Clock,
Users,
AlertTriangle,
FileText,
DollarSign
} from 'lucide-react';
interface Approval {
id: string;
proposalTitle: string;
amount: number;
category: string;
requester: string;
description: string;
requiredSignatures: number;
currentSignatures: number;
signers: Array<{
name: string;
status: 'approved' | 'rejected' | 'pending';
timestamp?: string;
comment?: string;
}>;
deadline: string;
status: 'pending' | 'approved' | 'rejected' | 'expired';
}
export const MultiSigApproval: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('pending');
const [approvals] = useState<Approval[]>([
{
id: '1',
proposalTitle: 'Infrastructure Upgrade - Q1 2024',
amount: 45000,
category: 'Infrastructure',
requester: 'Tech Team',
description: 'Upgrade cloud infrastructure for improved performance',
requiredSignatures: 3,
currentSignatures: 1,
signers: [
{ name: 'Alice', status: 'approved', timestamp: '2024-01-08 14:30', comment: 'Looks good' },
{ name: 'Bob', status: 'pending' },
{ name: 'Charlie', status: 'pending' },
{ name: 'Diana', status: 'pending' }
],
deadline: '2024-01-20',
status: 'pending'
},
{
id: '2',
proposalTitle: 'Developer Grants Program',
amount: 100000,
category: 'Development',
requester: 'Dev Relations',
description: 'Fund developer grants for ecosystem growth',
requiredSignatures: 4,
currentSignatures: 2,
signers: [
{ name: 'Alice', status: 'approved', timestamp: '2024-01-07 10:15' },
{ name: 'Bob', status: 'approved', timestamp: '2024-01-07 11:45' },
{ name: 'Charlie', status: 'pending' },
{ name: 'Diana', status: 'pending' },
{ name: 'Eve', status: 'pending' }
],
deadline: '2024-01-25',
status: 'pending'
}
]);
const pendingApprovals = approvals.filter(a => a.status === 'pending');
const approvedApprovals = approvals.filter(a => a.status === 'approved');
const rejectedApprovals = approvals.filter(a => a.status === 'rejected');
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'rejected':
return <XCircle className="h-4 w-4 text-red-500" />;
case 'pending':
return <Clock className="h-4 w-4 text-yellow-500" />;
default:
return null;
}
};
const ApprovalCard = ({ approval }: { approval: Approval }) => {
const progress = (approval.currentSignatures / approval.requiredSignatures) * 100;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{approval.proposalTitle}</CardTitle>
<CardDescription>{approval.description}</CardDescription>
</div>
<Badge variant="outline">{approval.category}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">${approval.amount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Deadline: {approval.deadline}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Approval Progress</span>
<span className="font-medium">
{approval.currentSignatures}/{approval.requiredSignatures} signatures
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Signers</p>
<div className="flex flex-wrap gap-2">
{approval.signers.map((signer, index) => (
<div key={index} className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{signer.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1">
<span className="text-sm">{signer.name}</span>
{getStatusIcon(signer.status)}
</div>
</div>
))}
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" size="sm">
<CheckCircle className="h-4 w-4 mr-2" />
Approve
</Button>
<Button variant="outline" className="flex-1" size="sm">
<XCircle className="h-4 w-4 mr-2" />
Reject
</Button>
<Button variant="ghost" size="sm">
View Details
</Button>
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
{/* Stats Overview */}
<div className="grid grid-cols-1 md: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">Pending Approvals</p>
<p className="text-2xl font-bold">{pendingApprovals.length}</p>
</div>
<Clock className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Value</p>
<p className="text-2xl font-bold">
${(pendingApprovals.reduce((sum, a) => sum + a.amount, 0) / 1000).toFixed(0)}k
</p>
</div>
<DollarSign 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 Signers</p>
<p className="text-2xl font-bold">5</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">Expiring Soon</p>
<p className="text-2xl font-bold">2</p>
</div>
<AlertTriangle className="h-8 w-8 text-orange-500" />
</div>
</CardContent>
</Card>
</div>
{/* Approvals Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="pending">
Pending ({pendingApprovals.length})
</TabsTrigger>
<TabsTrigger value="approved">
Approved ({approvedApprovals.length})
</TabsTrigger>
<TabsTrigger value="rejected">
Rejected ({rejectedApprovals.length})
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="space-y-4">
{pendingApprovals.map(approval => (
<ApprovalCard key={approval.id} approval={approval} />
))}
</TabsContent>
<TabsContent value="approved" className="space-y-4">
{approvedApprovals.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No approved proposals yet
</CardContent>
</Card>
) : (
approvedApprovals.map(approval => (
<ApprovalCard key={approval.id} approval={approval} />
))
)}
</TabsContent>
<TabsContent value="rejected" className="space-y-4">
{rejectedApprovals.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No rejected proposals
</CardContent>
</Card>
) : (
rejectedApprovals.map(approval => (
<ApprovalCard key={approval.id} approval={approval} />
))
)}
</TabsContent>
</Tabs>
</div>
);
};
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
import {
Download,
Filter,
Search,
ArrowUpDown,
FileText,
CheckCircle,
XCircle,
Clock,
TrendingUp,
TrendingDown
} from 'lucide-react';
interface Transaction {
id: string;
date: string;
description: string;
category: string;
amount: number;
status: 'completed' | 'pending' | 'rejected';
proposalId: string;
recipient: string;
approvers: string[];
}
export const SpendingHistory: React.FC = () => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [transactions] = useState<Transaction[]>([
{
id: '1',
date: '2024-01-15',
description: 'Q1 Development Team Salaries',
category: 'Development',
amount: 85000,
status: 'completed',
proposalId: 'PROP-001',
recipient: 'Dev Team Multisig',
approvers: ['Alice', 'Bob', 'Charlie']
},
{
id: '2',
date: '2024-01-10',
description: 'Marketing Campaign - Social Media',
category: 'Marketing',
amount: 25000,
status: 'completed',
proposalId: 'PROP-002',
recipient: 'Marketing Agency',
approvers: ['Alice', 'Diana']
},
{
id: '3',
date: '2024-01-08',
description: 'Infrastructure Upgrade - Servers',
category: 'Infrastructure',
amount: 45000,
status: 'pending',
proposalId: 'PROP-003',
recipient: 'Cloud Provider',
approvers: ['Bob']
},
{
id: '4',
date: '2024-01-05',
description: 'Community Hackathon Prizes',
category: 'Community',
amount: 15000,
status: 'completed',
proposalId: 'PROP-004',
recipient: 'Hackathon Winners',
approvers: ['Alice', 'Bob', 'Eve']
},
{
id: '5',
date: '2024-01-03',
description: 'Research Grant - DeFi Protocol',
category: 'Research',
amount: 50000,
status: 'rejected',
proposalId: 'PROP-005',
recipient: 'Research Lab',
approvers: []
}
]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'pending':
return <Clock className="h-4 w-4 text-yellow-500" />;
case 'rejected':
return <XCircle className="h-4 w-4 text-red-500" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge className="bg-green-100 text-green-800">Completed</Badge>;
case 'pending':
return <Badge className="bg-yellow-100 text-yellow-800">Pending</Badge>;
case 'rejected':
return <Badge className="bg-red-100 text-red-800">Rejected</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
const filteredTransactions = transactions.filter(tx => {
const matchesSearch = tx.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
tx.recipient.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || tx.category === filterCategory;
const matchesStatus = filterStatus === 'all' || tx.status === filterStatus;
return matchesSearch && matchesCategory && matchesStatus;
});
const totalSpent = transactions
.filter(tx => tx.status === 'completed')
.reduce((sum, tx) => sum + tx.amount, 0);
const pendingAmount = transactions
.filter(tx => tx.status === 'pending')
.reduce((sum, tx) => sum + tx.amount, 0);
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Spent (YTD)</p>
<p className="text-2xl font-bold">${(totalSpent / 1000).toFixed(0)}k</p>
</div>
<TrendingUp 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">Pending Approvals</p>
<p className="text-2xl font-bold">${(pendingAmount / 1000).toFixed(0)}k</p>
</div>
<Clock className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Transactions</p>
<p className="text-2xl font-bold">{transactions.length}</p>
</div>
<FileText className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
</div>
{/* Filters and Search */}
<Card>
<CardHeader>
<CardTitle>Transaction History</CardTitle>
<CardDescription>View and export treasury spending records</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="Development">Development</SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem>
<SelectItem value="Infrastructure">Infrastructure</SelectItem>
<SelectItem value="Community">Community</SelectItem>
<SelectItem value="Research">Research</SelectItem>
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
{/* Transactions Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead>Category</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Approvers</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTransactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell className="font-medium">{tx.date}</TableCell>
<TableCell>
<div>
<p className="font-medium">{tx.description}</p>
<p className="text-sm text-muted-foreground">{tx.recipient}</p>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{tx.category}</Badge>
</TableCell>
<TableCell className="font-semibold">
${tx.amount.toLocaleString()}
</TableCell>
<TableCell>{getStatusBadge(tx.status)}</TableCell>
<TableCell>
<div className="flex -space-x-2">
{tx.approvers.slice(0, 3).map((approver, i) => (
<div
key={i}
className="h-8 w-8 rounded-full bg-primary/10 border-2 border-background flex items-center justify-center text-xs font-medium"
title={approver}
>
{approver[0]}
</div>
))}
{tx.approvers.length > 3 && (
<div className="h-8 w-8 rounded-full bg-muted border-2 border-background flex items-center justify-center text-xs">
+{tx.approvers.length - 3}
</div>
)}
</div>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm">View</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import {
DollarSign,
TrendingUp,
TrendingDown,
PieChart,
Activity,
AlertCircle,
CheckCircle,
Clock,
ArrowUpRight,
ArrowDownRight
} from 'lucide-react';
interface TreasuryMetrics {
totalBalance: number;
monthlyIncome: number;
monthlyExpenses: number;
pendingProposals: number;
approvedBudget: number;
healthScore: number;
}
interface BudgetCategory {
id: string;
name: string;
allocated: number;
spent: number;
remaining: number;
color: string;
}
export const TreasuryOverview: React.FC = () => {
const { t } = useTranslation();
const [metrics, setMetrics] = useState<TreasuryMetrics>({
totalBalance: 2500000,
monthlyIncome: 150000,
monthlyExpenses: 120000,
pendingProposals: 8,
approvedBudget: 1800000,
healthScore: 85
});
const [categories] = useState<BudgetCategory[]>([
{ id: '1', name: 'Development', allocated: 500000, spent: 320000, remaining: 180000, color: 'bg-blue-500' },
{ id: '2', name: 'Marketing', allocated: 200000, spent: 150000, remaining: 50000, color: 'bg-purple-500' },
{ id: '3', name: 'Operations', allocated: 300000, spent: 180000, remaining: 120000, color: 'bg-green-500' },
{ id: '4', name: 'Community', allocated: 150000, spent: 80000, remaining: 70000, color: 'bg-yellow-500' },
{ id: '5', name: 'Research', allocated: 250000, spent: 100000, remaining: 150000, color: 'bg-pink-500' },
{ id: '6', name: 'Infrastructure', allocated: 400000, spent: 350000, remaining: 50000, color: 'bg-indigo-500' }
]);
const getHealthStatus = (score: number) => {
if (score >= 80) return { label: 'Excellent', color: 'text-green-500', icon: CheckCircle };
if (score >= 60) return { label: 'Good', color: 'text-blue-500', icon: Activity };
if (score >= 40) return { label: 'Fair', color: 'text-yellow-500', icon: AlertCircle };
return { label: 'Critical', color: 'text-red-500', icon: AlertCircle };
};
const healthStatus = getHealthStatus(metrics.healthScore);
const HealthIcon = healthStatus.icon;
return (
<div className="space-y-6">
{/* Treasury Health Score */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Treasury Health</span>
<HealthIcon className={`h-6 w-6 ${healthStatus.color}`} />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-2xl font-bold">{metrics.healthScore}%</span>
<Badge className={healthStatus.color}>{healthStatus.label}</Badge>
</div>
<Progress value={metrics.healthScore} className="h-3" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Runway</p>
<p className="font-semibold">20.8 months</p>
</div>
<div>
<p className="text-muted-foreground">Burn Rate</p>
<p className="font-semibold">$120k/month</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Key Metrics */}
<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 Balance</p>
<p className="text-2xl font-bold">${(metrics.totalBalance / 1000000).toFixed(2)}M</p>
<p className="text-xs text-green-500 flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 mr-1" />
+12.5% this month
</p>
</div>
<DollarSign 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">Monthly Income</p>
<p className="text-2xl font-bold">${(metrics.monthlyIncome / 1000).toFixed(0)}k</p>
<p className="text-xs text-green-500 flex items-center mt-1">
<TrendingUp className="h-3 w-3 mr-1" />
+8.3% vs last month
</p>
</div>
<TrendingUp 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">Monthly Expenses</p>
<p className="text-2xl font-bold">${(metrics.monthlyExpenses / 1000).toFixed(0)}k</p>
<p className="text-xs text-red-500 flex items-center mt-1">
<ArrowDownRight className="h-3 w-3 mr-1" />
-5.2% vs last month
</p>
</div>
<TrendingDown className="h-8 w-8 text-red-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pending Proposals</p>
<p className="text-2xl font-bold">{metrics.pendingProposals}</p>
<p className="text-xs text-yellow-500 flex items-center mt-1">
<Clock className="h-3 w-3 mr-1" />
$450k requested
</p>
</div>
<Clock className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
</div>
{/* Budget Categories */}
<Card>
<CardHeader>
<CardTitle>Budget Allocation by Category</CardTitle>
<CardDescription>Current quarter budget utilization</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{categories.map((category) => {
const utilization = (category.spent / category.allocated) * 100;
return (
<div key={category.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{category.name}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
${(category.spent / 1000).toFixed(0)}k / ${(category.allocated / 1000).toFixed(0)}k
</span>
<Badge variant={utilization > 80 ? 'destructive' : 'secondary'}>
{utilization.toFixed(0)}%
</Badge>
</div>
</div>
<Progress value={utilization} className="h-2" />
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
};
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border/50", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:text-primary [&[data-state=open]>svg]:rotate-180 [&[data-state=open]]:text-primary",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-300 ease-in-out text-muted-foreground" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm text-muted-foreground transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+139
View File
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-primary/90", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground mt-2", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground shadow-sm",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 text-green-600 dark:text-green-400 [&>svg]:text-green-600 dark:[&>svg]:text-green-400 bg-green-50 dark:bg-green-950/20",
warning:
"border-yellow-500/50 text-yellow-600 dark:text-yellow-400 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400 bg-yellow-50 dark:bg-yellow-950/20",
info:
"border-primary/50 text-primary dark:text-primary-foreground [&>svg]:text-primary bg-primary/10 dark:bg-primary/20",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed opacity-90", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+5
View File
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
size?: "sm" | "md" | "lg" | "xl"
}
>(({ className, size = "md", ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex shrink-0 overflow-hidden rounded-full border border-border/30 ring-offset-background",
size === "sm" && "h-8 w-8",
size === "md" && "h-10 w-10",
size === "lg" && "h-12 w-12",
size === "xl" && "h-16 w-16",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full object-cover", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground border-border",
success:
"border-transparent bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
warning:
"border-transparent bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
info:
"border-transparent bg-primary/10 text-primary border-primary/30",
},
size: {
default: "px-2.5 py-0.5 text-xs",
sm: "px-2 py-0.5 text-[10px]",
lg: "px-3 py-0.5 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-primary focus-visible:text-primary", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-medium text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 text-muted-foreground/50", className)}
{...props}
>
{children ?? <ChevronRight className="h-3.5 w-3.5" />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center text-muted-foreground", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium text-foreground",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline", size: "sm" }),
"h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100 transition-opacity"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:text-accent-foreground"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md transition-colors",
day_today: "bg-accent/50 text-accent-foreground rounded-md",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/30 aria-selected:text-muted-foreground aria-selected:opacity-40",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent/60 aria-selected:text-accent-foreground rounded-none",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border/40 bg-background shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight text-foreground",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+260
View File
@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full border border-border/40 opacity-80 hover:opacity-100 transition-opacity",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full border border-border/40 opacity-80 hover:opacity-100 transition-opacity",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+363
View File
@@ -0,0 +1,363 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/40 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border/60 [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border/40 [&_.recharts-radial-bar-background-sector]:fill-muted/50 [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/80 [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border/40 [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background/95 backdrop-blur-sm px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary/60 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground transition-colors duration-200",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5 transition-transform duration-200" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+9
View File
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+151
View File
@@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border/40 px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border/60", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent/60 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 transition-colors",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground/70",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+198
View File
@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current text-primary" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground/70",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/40 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-foreground",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border border-border bg-card shadow-lg",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted/50" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-primary/90",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground mt-2", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+198
View File
@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground/80",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground/70", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+176
View File
@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2 mb-4", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", "text-sm font-medium mb-1", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground/80 mt-1", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive mt-1", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input bg-background/50 text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-primary ring-offset-background border-primary/50",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-primary duration-700" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props} className="text-muted-foreground">
<Dot className="h-4 w-4" />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
{
variants: {
variant: {
default: "text-foreground",
muted: "text-muted-foreground",
accent: "text-primary",
},
size: {
default: "text-sm",
xs: "text-xs",
sm: "text-sm",
lg: "text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, variant, size, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants({ variant, size }), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+234
View File
@@ -0,0 +1,234 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border border-border/50 bg-background/50 p-1 shadow-sm",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current text-primary" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground/80",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground/70",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
+128
View File
@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background/50 px-4 py-2 text-sm font-medium transition-all hover:bg-accent/50 hover:text-accent-foreground focus:bg-accent/50 focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/60 data-[state=open]:bg-accent/60"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 ease-in-out group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 duration-200 md:absolute md:w-auto",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-primary/20 shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
isActive && "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10",
"transition-colors",
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5 hover:text-primary", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5 hover:text-primary", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center text-muted-foreground", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-border/40 bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
variant?: "default" | "success" | "warning" | "error"
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, variant = "default", ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary/40",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
"h-full w-full flex-1 transition-all duration-300 ease-in-out",
variant === "default" && "bg-primary",
variant === "success" && "bg-green-500",
variant === "warning" && "bg-yellow-500",
variant === "error" && "bg-destructive",
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary/60 text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current animate-in scale-in-0 duration-200" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
+43
View File
@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border/50 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border border-border/50 bg-border/30 hover:bg-border/50 transition-colors">
<GripVertical className="h-2.5 w-2.5 text-primary/40" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
hideScrollbar?: boolean
}
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
{!hideScrollbar && <ScrollBar />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors duration-300",
orientation === "vertical" &&
"h-full w-2 border-l border-l-transparent p-[1px] hover:w-2.5",
orientation === "horizontal" &&
"h-2 flex-col border-t border-t-transparent p-[1px] hover:h-2.5",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/50 hover:bg-border/80 transition-colors" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 transition-colors",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50 transition-transform duration-200 ease-in-out group-data-[state=open]:rotate-180" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1 text-muted-foreground",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1 text-muted-foreground",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-medium text-muted-foreground", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/50 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
interface SeparatorProps extends
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> {
variant?: "default" | "muted" | "accent"
}
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
SeparatorProps
>(
(
{ className, orientation = "horizontal", decorative = true, variant = "default", ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0",
variant === "default" && "bg-border",
variant === "muted" && "bg-muted",
variant === "accent" && "bg-primary/30",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+131
View File
@@ -0,0 +1,131 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-card border shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b rounded-b-xl data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t rounded-t-xl data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-primary/90", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground mt-2", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}
+738
View File
@@ -0,0 +1,738 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const sidebarVariants = cva(
"h-full bg-background/80 backdrop-blur-sm border-r border-border/40 shadow-sm",
{
variants: {
size: {
sm: "w-16",
md: "w-64",
lg: "w-80",
},
collapsible: {
true: "transition-all duration-300 ease-in-out",
},
},
defaultVariants: {
size: "md",
},
}
)
interface SidebarProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof sidebarVariants> {
collapsed?: boolean
}
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
(
{ className, size, collapsible, collapsed = false, children, ...props },
ref
) => {
const actualSize = collapsed ? "sm" : size
return (
<div
ref={ref}
className={cn(sidebarVariants({ size: actualSize, collapsible }), className)}
{...props}
>
{children}
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("p-4 border-b border-border/40", className)}
{...props}
/>
))
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("p-4 border-t border-border/40 mt-auto", className)}
{...props}
/>
))
SidebarFooter.displayName = "SidebarFooter"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col flex-1 p-2", className)} {...props} />
))
SidebarContent.displayName = "SidebarContent"
const SidebarNav = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<nav
ref={ref}
className={cn("flex flex-col gap-1", className)}
{...props}
/>
))
SidebarNav.displayName = "SidebarNav"
const SidebarNavItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { active?: boolean }
>(({ className, active, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex items-center px-3 py-2 rounded-md text-sm text-foreground/80 hover:text-foreground hover:bg-accent/50 transition-colors cursor-pointer",
active && "bg-accent/60 text-primary font-medium",
className
)}
{...props}
/>
))
SidebarNavItem.displayName = "SidebarNavItem"
const SidebarSection = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("mb-2", className)} {...props} />
))
SidebarSection.displayName = "SidebarSection"
const SidebarSectionTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-xs uppercase font-medium text-muted-foreground/70 tracking-wider px-3 py-1", className)}
{...props}
/>
))
SidebarSectionTitle.displayName = "SidebarSectionTitle"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
SidebarNav,
SidebarNavItem,
SidebarSection,
SidebarSectionTitle
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
animated?: boolean
}
function Skeleton({
className,
animated = true,
...props
}: SkeletonProps) {
return (
<div
className={cn(
"rounded-md bg-muted/70",
animated && "animate-pulse",
className
)}
{...props}
/>
)
}
export { Skeleton }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary/50">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:border-primary hover:scale-110" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

Some files were not shown because too many files have changed in this diff Show More