feat(p2p): implement OKX-style internal ledger escrow system

Phase 5 implementation - Internal Ledger Escrow (OKX Model):
- No blockchain transactions during P2P trades
- Blockchain tx only at deposit/withdraw
- Fast and fee-free P2P trading

Database:
- Add user_internal_balances table
- Add p2p_deposit_withdraw_requests table
- Add p2p_balance_transactions table
- Add lock_escrow_internal(), release_escrow_internal() functions
- Add process_deposit(), request_withdraw() functions

UI Components:
- Add InternalBalanceCard showing available/locked balances
- Add DepositModal for crypto deposits to P2P balance
- Add WithdrawModal for withdrawals from P2P balance
- Integrate balance card into P2PDashboard

Backend:
- Add process-withdrawal Edge Function
- Add verify-deposit Edge Function

Updated p2p-fiat.ts:
- createFiatOffer() uses internal balance lock
- confirmPaymentReceived() uses internal balance transfer
- Add internal balance management functions
This commit is contained in:
2025-12-12 00:41:11 +03:00
parent 98e03d11fd
commit 14f5e84d15
372 changed files with 3408 additions and 351 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import { TradeModal } from './TradeModal';
import { MerchantTierBadge } from './MerchantTierBadge';
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
import { supabase } from '@/lib/supabase';
import type { P2PFilters } from './OrderFilters';
import type { P2PFilters } from './types';
interface AdListProps {
type: 'buy' | 'sell' | 'my-ads';
+433
View File
@@ -0,0 +1,433 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import {
Loader2,
Copy,
CheckCircle2,
AlertTriangle,
ExternalLink,
QrCode,
Wallet
} from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { toast } from 'sonner';
import {
getPlatformWalletAddress,
verifyDeposit,
type CryptoToken
} from '@shared/lib/p2p-fiat';
interface DepositModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
type DepositStep = 'select' | 'send' | 'verify' | 'success';
export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) {
const { api, selectedAccount } = usePolkadot();
const { balances, signTransaction } = useWallet();
const [step, setStep] = useState<DepositStep>('select');
const [token, setToken] = useState<CryptoToken>('HEZ');
const [amount, setAmount] = useState('');
const [platformWallet, setPlatformWallet] = useState<string>('');
const [txHash, setTxHash] = useState('');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [verifying, setVerifying] = useState(false);
// Fetch platform wallet address on mount
useEffect(() => {
if (isOpen) {
fetchPlatformWallet();
}
}, [isOpen]);
const fetchPlatformWallet = async () => {
const address = await getPlatformWalletAddress();
setPlatformWallet(address);
};
const resetModal = () => {
setStep('select');
setToken('HEZ');
setAmount('');
setTxHash('');
setLoading(false);
setCopied(false);
setVerifying(false);
};
const handleClose = () => {
resetModal();
onClose();
};
const handleCopyAddress = async () => {
try {
await navigator.clipboard.writeText(platformWallet);
setCopied(true);
toast.success('Address copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy address');
}
};
const getAvailableBalance = () => {
if (token === 'HEZ') return balances.HEZ;
if (token === 'PEZ') return balances.PEZ;
return '0';
};
const handleSendDeposit = async () => {
if (!api || !selectedAccount) {
toast.error('Please connect your wallet');
return;
}
const depositAmount = parseFloat(amount);
if (isNaN(depositAmount) || depositAmount <= 0) {
toast.error('Please enter a valid amount');
return;
}
setLoading(true);
try {
// Build the transfer transaction
const DECIMALS = 12;
const amountBN = BigInt(Math.floor(depositAmount * 10 ** DECIMALS));
let tx;
if (token === 'HEZ') {
// Native transfer
tx = api.tx.balances.transferKeepAlive(platformWallet, amountBN);
} else {
// Asset transfer (PEZ = asset ID 1)
const assetId = token === 'PEZ' ? 1 : 0;
tx = api.tx.assets.transfer(assetId, platformWallet, amountBN);
}
toast.info('Please sign the transaction in your wallet...');
// Sign and send
const hash = await signTransaction(tx);
if (hash) {
setTxHash(hash);
setStep('verify');
toast.success('Transaction sent! Please verify your deposit.');
}
} catch (error: unknown) {
console.error('Deposit transaction error:', error);
const message = error instanceof Error ? error.message : 'Transaction failed';
toast.error(message);
} finally {
setLoading(false);
}
};
const handleVerifyDeposit = async () => {
if (!txHash) {
toast.error('Please enter the transaction hash');
return;
}
const depositAmount = parseFloat(amount);
if (isNaN(depositAmount) || depositAmount <= 0) {
toast.error('Invalid amount');
return;
}
setVerifying(true);
try {
const success = await verifyDeposit(txHash, token, depositAmount);
if (success) {
setStep('success');
onSuccess?.();
}
} catch (error) {
console.error('Verify deposit error:', error);
} finally {
setVerifying(false);
}
};
const renderStepContent = () => {
switch (step) {
case 'select':
return (
<div className="space-y-6">
<div className="space-y-2">
<Label>Select Token</Label>
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ (Native)</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Amount to Deposit</Label>
<div className="relative">
<Input
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="0.0001"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{token}
</div>
</div>
<p className="text-xs text-muted-foreground">
Wallet Balance: {parseFloat(getAvailableBalance()).toFixed(4)} {token}
</p>
</div>
<Alert>
<Wallet className="h-4 w-4" />
<AlertDescription>
You will send {token} from your connected wallet to the P2P platform escrow.
After confirmation, the amount will be credited to your P2P internal balance.
</AlertDescription>
</Alert>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={() => setStep('send')}
disabled={!amount || parseFloat(amount) <= 0}
>
Continue
</Button>
</DialogFooter>
</div>
);
case 'send':
return (
<div className="space-y-6">
<div className="p-4 rounded-lg bg-muted/50 border space-y-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-3 rounded-xl bg-primary/10 flex items-center justify-center">
<QrCode className="h-8 w-8 text-primary" />
</div>
<p className="text-sm font-medium">Send {amount} {token} to:</p>
</div>
{platformWallet ? (
<div className="space-y-2">
<div className="p-3 rounded-lg bg-background border font-mono text-xs break-all">
{platformWallet}
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleCopyAddress}
>
{copied ? (
<>
<CheckCircle2 className="h-4 w-4 mr-2 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy Address
</>
)}
</Button>
</div>
) : (
<Skeleton className="h-16 w-full" />
)}
</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Only send {token} on the PezkuwiChain network. Sending other tokens or using
other networks will result in permanent loss of funds.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setStep('select')}
>
Back
</Button>
<Button
className="flex-1"
onClick={handleSendDeposit}
disabled={loading || !platformWallet}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
Send {amount} {token}
</>
)}
</Button>
</div>
</div>
);
case 'verify':
return (
<div className="space-y-6">
<Alert>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
Transaction sent! Please verify your deposit to credit your P2P balance.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>Transaction Hash</Label>
<div className="flex gap-2">
<Input
value={txHash}
onChange={(e) => setTxHash(e.target.value)}
placeholder="0x..."
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={() => window.open(`https://explorer.pezkuwichain.io/tx/${txHash}`, '_blank')}
disabled={!txHash}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 border">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Token</p>
<p className="font-semibold">{token}</p>
</div>
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-semibold">{amount}</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleVerifyDeposit}
disabled={verifying || !txHash}
>
{verifying ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Verify Deposit'
)}
</Button>
</DialogFooter>
</div>
);
case 'success':
return (
<div className="space-y-6 text-center">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<div>
<h3 className="text-xl font-semibold text-green-500">
Deposit Successful!
</h3>
<p className="text-muted-foreground mt-2">
{amount} {token} has been added to your P2P internal balance.
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border">
<p className="text-sm text-muted-foreground">
You can now create sell offers or trade P2P using your internal balance.
No blockchain fees during P2P trades!
</p>
</div>
<Button onClick={handleClose} className="w-full">
Done
</Button>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wallet className="h-5 w-5" />
Deposit to P2P Balance
</DialogTitle>
{step !== 'success' && (
<DialogDescription>
{step === 'select' && 'Deposit crypto from your wallet to P2P internal balance'}
{step === 'send' && 'Send tokens to the platform escrow wallet'}
{step === 'verify' && 'Verify your transaction to credit your balance'}
</DialogDescription>
)}
</DialogHeader>
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import {
Wallet,
ArrowDownToLine,
ArrowUpFromLine,
RefreshCw,
Lock,
Unlock
} from 'lucide-react';
import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat';
interface InternalBalanceCardProps {
onDeposit?: () => void;
onWithdraw?: () => void;
}
export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCardProps) {
const [balances, setBalances] = useState<InternalBalance[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchBalances = async () => {
try {
const data = await getInternalBalances();
setBalances(data);
} catch (error) {
console.error('Failed to fetch balances:', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
};
useEffect(() => {
fetchBalances();
}, []);
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchBalances();
};
const formatBalance = (value: number, decimals: number = 4) => {
return value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
};
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Wallet className="h-5 w-5" />
P2P Internal Balance
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Internal balance for P2P trading. Deposit to start selling.
</p>
</CardHeader>
<CardContent className="space-y-4">
{balances.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Wallet className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No balance yet</p>
<p className="text-xs">Deposit crypto to start P2P trading</p>
</div>
) : (
balances.map((balance) => (
<div
key={balance.token}
className="p-4 rounded-lg bg-muted/50 border"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-xs font-bold text-primary">
{balance.token.charAt(0)}
</span>
</div>
<span className="font-semibold">{balance.token}</span>
</div>
<Badge variant="outline" className="text-xs">
Total: {formatBalance(balance.total_balance)}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Unlock className="h-4 w-4 text-green-500" />
<div>
<p className="text-muted-foreground text-xs">Available</p>
<p className="font-medium text-green-600">
{formatBalance(balance.available_balance)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-yellow-500" />
<div>
<p className="text-muted-foreground text-xs">Locked (Escrow)</p>
<p className="font-medium text-yellow-600">
{formatBalance(balance.locked_balance)}
</p>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div>
<span>Total Deposited: </span>
<span className="text-foreground">{formatBalance(balance.total_deposited, 2)}</span>
</div>
<div>
<span>Total Withdrawn: </span>
<span className="text-foreground">{formatBalance(balance.total_withdrawn, 2)}</span>
</div>
</div>
</div>
))
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant="default"
className="flex-1"
onClick={onDeposit}
>
<ArrowDownToLine className="h-4 w-4 mr-2" />
Deposit
</Button>
<Button
variant="outline"
className="flex-1"
onClick={onWithdraw}
disabled={balances.every(b => b.available_balance <= 0)}
>
<ArrowUpFromLine className="h-4 w-4 mr-2" />
Withdraw
</Button>
</div>
</CardContent>
</Card>
);
}
+2 -46
View File
@@ -21,52 +21,8 @@ import {
Check
} from 'lucide-react';
// Filter options
export interface P2PFilters {
// Token
token: 'HEZ' | 'PEZ' | 'all';
// Fiat currency
fiatCurrency: string | 'all';
// Payment methods
paymentMethods: string[];
// Amount range
minAmount: number | null;
maxAmount: number | null;
// Merchant tier
merchantTiers: ('lite' | 'super' | 'diamond')[];
// Completion rate
minCompletionRate: number;
// Online status
onlineOnly: boolean;
// Verified only
verifiedOnly: boolean;
// Sort
sortBy: 'price' | 'completion_rate' | 'trades' | 'newest';
sortOrder: 'asc' | 'desc';
}
// Default filters
export const DEFAULT_FILTERS: P2PFilters = {
token: 'all',
fiatCurrency: 'all',
paymentMethods: [],
minAmount: null,
maxAmount: null,
merchantTiers: [],
minCompletionRate: 0,
onlineOnly: false,
verifiedOnly: false,
sortBy: 'price',
sortOrder: 'asc'
};
// Import types from separate file to avoid react-refresh warning
import { type P2PFilters, DEFAULT_FILTERS } from './types';
// Available fiat currencies
const FIAT_CURRENCIES = [
+73 -36
View File
@@ -8,7 +8,11 @@ import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store
import { AdList } from './AdList';
import { CreateAd } from './CreateAd';
import { NotificationBell } from './NotificationBell';
import { QuickFilterBar, DEFAULT_FILTERS, type P2PFilters } from './OrderFilters';
import { QuickFilterBar } from './OrderFilters';
import { InternalBalanceCard } from './InternalBalanceCard';
import { DepositModal } from './DepositModal';
import { WithdrawModal } from './WithdrawModal';
import { DEFAULT_FILTERS, type P2PFilters } from './types';
import { useAuth } from '@/contexts/AuthContext';
import { supabase } from '@/lib/supabase';
@@ -22,9 +26,16 @@ export function P2PDashboard() {
const [showCreateAd, setShowCreateAd] = useState(false);
const [userStats, setUserStats] = useState<UserStats>({ activeTrades: 0, completedTrades: 0, totalVolume: 0 });
const [filters, setFilters] = useState<P2PFilters>(DEFAULT_FILTERS);
const [showDepositModal, setShowDepositModal] = useState(false);
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [balanceRefreshKey, setBalanceRefreshKey] = useState(0);
const navigate = useNavigate();
const { user } = useAuth();
const handleBalanceUpdated = () => {
setBalanceRefreshKey(prev => prev + 1);
};
// Fetch user stats
useEffect(() => {
const fetchStats = async () => {
@@ -104,42 +115,54 @@ export function P2PDashboard() {
</div>
</div>
{/* Stats Cards */}
{/* Stats Cards and Balance Card */}
{user && (
<div className="grid grid-cols-3 gap-4 mb-6">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-yellow-500/20 rounded-lg">
<Clock className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{userStats.activeTrades}</p>
<p className="text-sm text-gray-400">Active Trades</p>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-green-500/20 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{userStats.completedTrades}</p>
<p className="text-sm text-gray-400">Completed</p>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg">
<TrendingUp className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">${userStats.totalVolume.toLocaleString()}</p>
<p className="text-sm text-gray-400">Volume</p>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
{/* Internal Balance Card - Takes more space */}
<div className="lg:col-span-1">
<InternalBalanceCard
key={balanceRefreshKey}
onDeposit={() => setShowDepositModal(true)}
onWithdraw={() => setShowWithdrawModal(true)}
/>
</div>
{/* Stats Cards */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-yellow-500/20 rounded-lg">
<Clock className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{userStats.activeTrades}</p>
<p className="text-sm text-gray-400">Active Trades</p>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-green-500/20 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{userStats.completedTrades}</p>
<p className="text-sm text-gray-400">Completed</p>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg">
<TrendingUp className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">${userStats.totalVolume.toLocaleString()}</p>
<p className="text-sm text-gray-400">Volume</p>
</div>
</CardContent>
</Card>
</div>
</div>
)}
@@ -179,6 +202,20 @@ export function P2PDashboard() {
</Tabs>
</>
)}
{/* Deposit Modal */}
<DepositModal
isOpen={showDepositModal}
onClose={() => setShowDepositModal(false)}
onSuccess={handleBalanceUpdated}
/>
{/* Withdraw Modal */}
<WithdrawModal
isOpen={showWithdrawModal}
onClose={() => setShowWithdrawModal(false)}
onSuccess={handleBalanceUpdated}
/>
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import {
Loader2,
CheckCircle2,
AlertTriangle,
ArrowUpFromLine,
Clock,
Info
} from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { toast } from 'sonner';
import {
getInternalBalances,
requestWithdraw,
getDepositWithdrawHistory,
type CryptoToken,
type InternalBalance,
type DepositWithdrawRequest
} from '@shared/lib/p2p-fiat';
interface WithdrawModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
type WithdrawStep = 'form' | 'confirm' | 'success';
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
const { selectedAccount } = usePolkadot();
const [step, setStep] = useState<WithdrawStep>('form');
const [token, setToken] = useState<CryptoToken>('HEZ');
const [amount, setAmount] = useState('');
const [walletAddress, setWalletAddress] = useState('');
const [balances, setBalances] = useState<InternalBalance[]>([]);
const [pendingRequests, setPendingRequests] = useState<DepositWithdrawRequest[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [requestId, setRequestId] = useState<string>('');
// Network fee estimate (in HEZ)
const NETWORK_FEE = 0.01;
const MIN_WITHDRAWAL = 0.1;
// Fetch balances and pending requests on mount
useEffect(() => {
if (isOpen) {
fetchData();
// Pre-fill wallet address from connected account
if (selectedAccount?.address) {
setWalletAddress(selectedAccount.address);
}
}
}, [isOpen, selectedAccount]);
const fetchData = async () => {
setLoading(true);
try {
const [balanceData, historyData] = await Promise.all([
getInternalBalances(),
getDepositWithdrawHistory()
]);
setBalances(balanceData);
// Filter for pending withdrawal requests
setPendingRequests(
historyData.filter(r => r.request_type === 'withdraw' && r.status === 'pending')
);
} catch (error) {
console.error('Fetch data error:', error);
} finally {
setLoading(false);
}
};
const resetModal = () => {
setStep('form');
setAmount('');
setSubmitting(false);
setRequestId('');
};
const handleClose = () => {
resetModal();
onClose();
};
const getAvailableBalance = (): number => {
const balance = balances.find(b => b.token === token);
return balance?.available_balance || 0;
};
const getLockedBalance = (): number => {
const balance = balances.find(b => b.token === token);
return balance?.locked_balance || 0;
};
const getMaxWithdrawable = (): number => {
const available = getAvailableBalance();
// Subtract network fee for HEZ
if (token === 'HEZ') {
return Math.max(0, available - NETWORK_FEE);
}
return available;
};
const handleSetMax = () => {
const max = getMaxWithdrawable();
setAmount(max.toFixed(4));
};
const validateWithdrawal = (): string | null => {
const withdrawAmount = parseFloat(amount);
if (isNaN(withdrawAmount) || withdrawAmount <= 0) {
return 'Please enter a valid amount';
}
if (withdrawAmount < MIN_WITHDRAWAL) {
return `Minimum withdrawal is ${MIN_WITHDRAWAL} ${token}`;
}
if (withdrawAmount > getMaxWithdrawable()) {
return 'Insufficient available balance';
}
if (!walletAddress || walletAddress.length < 40) {
return 'Please enter a valid wallet address';
}
// Check for pending requests
const hasPendingForToken = pendingRequests.some(r => r.token === token);
if (hasPendingForToken) {
return `You already have a pending ${token} withdrawal request`;
}
return null;
};
const handleContinue = () => {
const error = validateWithdrawal();
if (error) {
toast.error(error);
return;
}
setStep('confirm');
};
const handleSubmitWithdrawal = async () => {
const error = validateWithdrawal();
if (error) {
toast.error(error);
return;
}
setSubmitting(true);
try {
const withdrawAmount = parseFloat(amount);
const id = await requestWithdraw(token, withdrawAmount, walletAddress);
setRequestId(id);
setStep('success');
onSuccess?.();
} catch (error) {
console.error('Submit withdrawal error:', error);
} finally {
setSubmitting(false);
}
};
const renderFormStep = () => (
<div className="space-y-6">
{loading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<>
{/* Token Selection */}
<div className="space-y-2">
<Label>Select Token</Label>
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ (Native)</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
</div>
{/* Balance Display */}
<div className="p-4 rounded-lg bg-muted/50 border">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Available</p>
<p className="font-semibold text-green-500">
{getAvailableBalance().toFixed(4)} {token}
</p>
</div>
<div>
<p className="text-muted-foreground">Locked (Escrow)</p>
<p className="font-semibold text-yellow-500">
{getLockedBalance().toFixed(4)} {token}
</p>
</div>
</div>
</div>
{/* Amount Input */}
<div className="space-y-2">
<Label>Withdrawal Amount</Label>
<div className="relative">
<Input
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="0.0001"
/>
<div className="absolute right-14 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{token}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 -translate-y-1/2 h-7 text-xs"
onClick={handleSetMax}
>
MAX
</Button>
</div>
<p className="text-xs text-muted-foreground">
Min: {MIN_WITHDRAWAL} {token} | Max: {getMaxWithdrawable().toFixed(4)} {token}
</p>
</div>
{/* Wallet Address */}
<div className="space-y-2">
<Label>Destination Wallet Address</Label>
<Input
value={walletAddress}
onChange={(e) => setWalletAddress(e.target.value)}
placeholder="5..."
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Only PezkuwiChain addresses are supported
</p>
</div>
{/* Network Fee Info */}
{token === 'HEZ' && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Network fee: ~{NETWORK_FEE} HEZ (deducted from withdrawal amount)
</AlertDescription>
</Alert>
)}
{/* Pending Requests Warning */}
{pendingRequests.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
You have {pendingRequests.length} pending withdrawal request(s).
Please wait for them to complete.
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleContinue}
disabled={!amount || parseFloat(amount) <= 0}
>
Continue
</Button>
</DialogFooter>
</>
)}
</div>
);
const renderConfirmStep = () => {
const withdrawAmount = parseFloat(amount);
const receiveAmount = token === 'HEZ' ? withdrawAmount - NETWORK_FEE : withdrawAmount;
return (
<div className="space-y-6">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please review your withdrawal details carefully.
This action cannot be undone.
</AlertDescription>
</Alert>
<div className="p-4 rounded-lg bg-muted/50 border space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Token</span>
<span className="font-semibold">{token}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Withdrawal Amount</span>
<span className="font-semibold">{withdrawAmount.toFixed(4)} {token}</span>
</div>
{token === 'HEZ' && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Network Fee</span>
<span className="text-yellow-500">-{NETWORK_FEE} HEZ</span>
</div>
)}
<div className="border-t pt-4 flex justify-between items-center">
<span className="text-muted-foreground">You Will Receive</span>
<span className="font-bold text-lg text-green-500">
{receiveAmount.toFixed(4)} {token}
</span>
</div>
</div>
<div className="p-4 rounded-lg bg-muted/30 border">
<p className="text-xs text-muted-foreground mb-1">Destination Address</p>
<p className="font-mono text-xs break-all">{walletAddress}</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Processing time: Usually within 5-30 minutes</span>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStep('form')}>
Back
</Button>
<Button
onClick={handleSubmitWithdrawal}
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<ArrowUpFromLine className="h-4 w-4 mr-2" />
Confirm Withdrawal
</>
)}
</Button>
</DialogFooter>
</div>
);
};
const renderSuccessStep = () => (
<div className="space-y-6 text-center">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<div>
<h3 className="text-xl font-semibold text-green-500">
Withdrawal Request Submitted!
</h3>
<p className="text-muted-foreground mt-2">
Your withdrawal request has been submitted for processing.
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border space-y-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Request ID</span>
<Badge variant="outline" className="font-mono text-xs">
{requestId.slice(0, 8)}...
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<Badge className="bg-yellow-500/20 text-yellow-500 border-yellow-500/30">
<Clock className="h-3 w-3 mr-1" />
Processing
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-semibold">{amount} {token}</span>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You can track your withdrawal status in the transaction history.
Funds will arrive in your wallet within 5-30 minutes.
</AlertDescription>
</Alert>
<Button onClick={handleClose} className="w-full">
Done
</Button>
</div>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowUpFromLine className="h-5 w-5" />
Withdraw from P2P Balance
</DialogTitle>
{step !== 'success' && (
<DialogDescription>
{step === 'form' && 'Withdraw crypto from your P2P balance to external wallet'}
{step === 'confirm' && 'Review and confirm your withdrawal'}
</DialogDescription>
)}
</DialogHeader>
{step === 'form' && renderFormStep()}
{step === 'confirm' && renderConfirmStep()}
{step === 'success' && renderSuccessStep()}
</DialogContent>
</Dialog>
);
}
+46
View File
@@ -0,0 +1,46 @@
// P2P Filter types and defaults - separate file to avoid react-refresh warning
export interface P2PFilters {
// Token
token: 'HEZ' | 'PEZ' | 'all';
// Fiat currency
fiatCurrency: string | 'all';
// Payment methods
paymentMethods: string[];
// Amount range
minAmount: number | null;
maxAmount: number | null;
// Merchant tier
merchantTiers: ('lite' | 'super' | 'diamond')[];
// Completion rate
minCompletionRate: number;
// Online status
onlineOnly: boolean;
// Verified only
verifiedOnly: boolean;
// Sort
sortBy: 'price' | 'completion_rate' | 'trades' | 'newest';
sortOrder: 'asc' | 'desc';
}
export const DEFAULT_FILTERS: P2PFilters = {
token: 'all',
fiatCurrency: 'all',
paymentMethods: [],
minAmount: null,
maxAmount: null,
merchantTiers: [],
minCompletionRate: 0,
onlineOnly: false,
verifiedOnly: false,
sortBy: 'price',
sortOrder: 'asc'
};