mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +00:00
2150ab4997
- requestWithdraw() calls process-withdraw edge function for full flow (lock balance + blockchain TX + complete) instead of just DB lock - Remove pending request block from withdrawal validation — backend already enforces available_balance with FOR UPDATE row lock - Pending requests shown as info alert instead of blocking error - Balance card: numbers right-aligned, 2 decimal places
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Loader2,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
ArrowUpFromLine,
|
|
Clock,
|
|
Info
|
|
} from 'lucide-react';
|
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
|
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 { t } = useTranslation();
|
|
const { selectedAccount } = usePezkuwi();
|
|
const { userId } = useP2PIdentity();
|
|
|
|
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 {
|
|
if (!userId) return;
|
|
const [balanceData, historyData] = await Promise.all([
|
|
getInternalBalances(userId),
|
|
getDepositWithdrawHistory(userId)
|
|
]);
|
|
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 t('p2pWithdraw.enterValidAmount');
|
|
}
|
|
|
|
if (withdrawAmount < MIN_WITHDRAWAL) {
|
|
return t('p2pWithdraw.minimumWithdrawal', { amount: MIN_WITHDRAWAL, token });
|
|
}
|
|
|
|
if (withdrawAmount > getMaxWithdrawable()) {
|
|
return t('p2pWithdraw.insufficientBalance');
|
|
}
|
|
|
|
if (!walletAddress || walletAddress.length < 40) {
|
|
return t('p2pWithdraw.invalidAddress');
|
|
}
|
|
|
|
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);
|
|
if (!userId) throw new Error('Identity required');
|
|
const id = await requestWithdraw(userId, 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>{t('p2pWithdraw.selectToken')}</Label>
|
|
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="HEZ">{t('p2pWithdraw.hezNative')}</SelectItem>
|
|
<SelectItem value="PEZ">{t('p2pWithdraw.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">{t('p2pWithdraw.available')}</p>
|
|
<p className="font-semibold text-green-500">
|
|
{getAvailableBalance().toFixed(4)} {token}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">{t('p2pWithdraw.lockedEscrow')}</p>
|
|
<p className="font-semibold text-yellow-500">
|
|
{getLockedBalance().toFixed(4)} {token}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Amount Input */}
|
|
<div className="space-y-2">
|
|
<Label>{t('p2pWithdraw.withdrawalAmount')}</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}
|
|
>
|
|
{t('p2pWithdraw.max')}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('p2pWithdraw.minMax', { min: MIN_WITHDRAWAL, max: getMaxWithdrawable().toFixed(4), token })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Wallet Address */}
|
|
<div className="space-y-2">
|
|
<Label>{t('p2pWithdraw.destinationAddress')}</Label>
|
|
<Input
|
|
value={walletAddress}
|
|
onChange={(e) => setWalletAddress(e.target.value)}
|
|
placeholder="5..."
|
|
className="font-mono text-xs"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('p2pWithdraw.onlyPezkuwiAddresses')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Network Fee Info */}
|
|
{token === 'HEZ' && (
|
|
<Alert>
|
|
<Info className="h-4 w-4" />
|
|
<AlertDescription>
|
|
{t('p2pWithdraw.networkFee', { fee: NETWORK_FEE })}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Pending Requests Info */}
|
|
{pendingRequests.length > 0 && (
|
|
<Alert>
|
|
<Info className="h-4 w-4" />
|
|
<AlertDescription>
|
|
{t('p2pWithdraw.pendingWarning', { count: pendingRequests.length })}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={handleClose}>
|
|
{t('cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleContinue}
|
|
disabled={!amount || parseFloat(amount) <= 0}
|
|
>
|
|
{t('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>
|
|
{t('p2pWithdraw.reviewWarning')}
|
|
</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">{t('p2pWithdraw.tokenLabel')}</span>
|
|
<span className="font-semibold">{token}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-muted-foreground">{t('p2pWithdraw.withdrawalAmountLabel')}</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">{t('p2pWithdraw.networkFeeLabel')}</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">{t('p2pWithdraw.youWillReceive')}</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">{t('p2pWithdraw.destinationAddressLabel')}</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>{t('p2pWithdraw.processingTime')}</span>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setStep('form')}>
|
|
{t('back')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmitWithdrawal}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('p2pWithdraw.processing')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ArrowUpFromLine className="h-4 w-4 mr-2" />
|
|
{t('p2pWithdraw.confirmWithdrawal')}
|
|
</>
|
|
)}
|
|
</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">
|
|
{t('p2pWithdraw.requestSubmitted')}
|
|
</h3>
|
|
<p className="text-muted-foreground mt-2">
|
|
{t('p2pWithdraw.requestSubmittedDesc')}
|
|
</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">{t('p2pWithdraw.requestId')}</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">{t('p2pWithdraw.statusLabel')}</span>
|
|
<Badge className="bg-yellow-500/20 text-yellow-500 border-yellow-500/30">
|
|
<Clock className="h-3 w-3 mr-1" />
|
|
{t('p2pWithdraw.statusProcessing')}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">{t('p2pWithdraw.amountLabel')}</span>
|
|
<span className="font-semibold">{amount} {token}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Alert>
|
|
<Info className="h-4 w-4" />
|
|
<AlertDescription>
|
|
{t('p2pWithdraw.trackInfo')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<Button onClick={handleClose} className="w-full">
|
|
{t('p2pWithdraw.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" />
|
|
{t('p2pWithdraw.title')}
|
|
</DialogTitle>
|
|
{step !== 'success' && (
|
|
<DialogDescription>
|
|
{step === 'form' && t('p2pWithdraw.formStep')}
|
|
{step === 'confirm' && t('p2pWithdraw.confirmStep')}
|
|
</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
{step === 'form' && renderFormStep()}
|
|
{step === 'confirm' && renderConfirmStep()}
|
|
{step === 'success' && renderSuccessStep()}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|