mirror of
https://github.com/pezkuwichain/pezkuwi-p2p-mobile.git
synced 2026-04-22 01:57:56 +00:00
feat: mobile-optimized P2P with URL param auth support
- Remove blockchain dependencies (@pezkuwi/api) - Use internal ledger for P2P trades - Add URL param authentication from mini-app redirect - State-based navigation instead of react-router-dom - Simplified deposit/withdraw modals
This commit is contained in:
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
Generated
+4614
File diff suppressed because it is too large
Load Diff
+18
-5
@@ -10,20 +10,32 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@pezkuwi/api": "^16.5.18",
|
||||
"@pezkuwi/keyring": "^14.0.13",
|
||||
"@pezkuwi/util": "^14.0.13",
|
||||
"@pezkuwi/util-crypto": "^14.0.13",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
@@ -33,12 +45,13 @@
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Loader2, Shield, Zap } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { TradeModal } from './TradeModal';
|
||||
import { MerchantTierBadge } from './MerchantTierBadge';
|
||||
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
||||
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@/lib/p2p-fiat';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { P2PFilters } from './types';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PaymentMethod,
|
||||
type FiatCurrency,
|
||||
type CryptoToken
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
} from '@/lib/p2p-fiat';
|
||||
|
||||
interface CreateAdProps {
|
||||
onAdCreated: () => void;
|
||||
|
||||
+100
-264
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Deposit Modal - Mobile P2P
|
||||
*
|
||||
* Shows platform wallet address for user to send tokens.
|
||||
* After sending, user enters tx hash to verify deposit.
|
||||
* Actual verification happens in backend Edge Function.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,24 +25,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Loader2,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
QrCode,
|
||||
Wallet
|
||||
} from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import {
|
||||
getPlatformWalletAddress,
|
||||
type CryptoToken
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
import { getPlatformWalletAddress, type CryptoToken } from '@/lib/p2p-fiat';
|
||||
|
||||
interface DepositModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -43,22 +43,17 @@ interface DepositModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type DepositStep = 'select' | 'send' | 'verify' | 'success';
|
||||
type DepositStep = 'info' | 'verify' | 'success';
|
||||
|
||||
export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) {
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const { balances, signTransaction } = useWallet();
|
||||
|
||||
const [step, setStep] = useState<DepositStep>('select');
|
||||
const [step, setStep] = useState<DepositStep>('info');
|
||||
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();
|
||||
@@ -71,11 +66,10 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setStep('select');
|
||||
setStep('info');
|
||||
setToken('HEZ');
|
||||
setAmount('');
|
||||
setTxHash('');
|
||||
setLoading(false);
|
||||
setCopied(false);
|
||||
setVerifying(false);
|
||||
};
|
||||
@@ -89,64 +83,11 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
||||
try {
|
||||
await navigator.clipboard.writeText(platformWallet);
|
||||
setCopied(true);
|
||||
toast.success('Address copied to clipboard');
|
||||
toast.success('Address copied!');
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
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);
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,50 +99,56 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
||||
|
||||
const depositAmount = parseFloat(amount);
|
||||
if (isNaN(depositAmount) || depositAmount <= 0) {
|
||||
toast.error('Invalid amount');
|
||||
toast.error('Please enter the deposit amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
|
||||
try {
|
||||
// Call the Edge Function for secure deposit verification
|
||||
// This verifies the transaction on-chain before crediting balance
|
||||
const { data, error } = await supabase.functions.invoke('verify-deposit', {
|
||||
body: {
|
||||
txHash,
|
||||
token,
|
||||
expectedAmount: depositAmount
|
||||
}
|
||||
body: { txHash, token, expectedAmount: depositAmount }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Verification failed');
|
||||
}
|
||||
if (error) throw new Error(error.message || 'Verification failed');
|
||||
|
||||
if (data?.success) {
|
||||
toast.success(`Deposit verified! ${data.amount} ${token} added to your balance.`);
|
||||
toast.success(`Deposit verified! ${data.amount} ${token} added.`);
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
setStep('success');
|
||||
onSuccess?.();
|
||||
} else {
|
||||
throw new Error(data?.error || 'Verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verify deposit error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Verification failed';
|
||||
toast.error(message);
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('error');
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Deposit
|
||||
</DialogTitle>
|
||||
{step !== 'success' && (
|
||||
<DialogDescription>
|
||||
{step === 'info' && 'Send tokens to the platform wallet'}
|
||||
{step === 'verify' && 'Enter transaction hash to verify'}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'info' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Token</Label>
|
||||
<Label>Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -214,133 +161,61 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
||||
</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>
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min="0"
|
||||
step="0.0001"
|
||||
/>
|
||||
</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 className="p-4 rounded-lg bg-muted/50 border space-y-3">
|
||||
<p className="text-sm font-medium text-center">Send {token} to:</p>
|
||||
<div className="p-2 rounded bg-background border font-mono text-xs break-all text-center">
|
||||
{platformWallet || 'Loading...'}
|
||||
</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" />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleCopyAddress}
|
||||
disabled={!platformWallet}
|
||||
>
|
||||
{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>
|
||||
|
||||
<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 className="text-xs">
|
||||
Only send {token} on PezkuwiChain. Wrong network = lost funds.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setStep('select')}
|
||||
>
|
||||
Back
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep('verify')}
|
||||
disabled={!amount || parseFloat(amount) <= 0}
|
||||
className="flex-1"
|
||||
onClick={handleSendDeposit}
|
||||
disabled={loading || !platformWallet}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Send {amount} {token}
|
||||
</>
|
||||
)}
|
||||
I've Sent
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{step === 'verify' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Transaction Hash</Label>
|
||||
<div className="flex gap-2">
|
||||
@@ -361,89 +236,50 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
||||
</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 className="p-3 rounded-lg bg-muted/50 border text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Token</span>
|
||||
<span className="font-medium">{token}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-muted-foreground">Amount</span>
|
||||
<span className="font-medium">{amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('info')} className="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyDeposit}
|
||||
disabled={verifying || !txHash}
|
||||
className="flex-1"
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
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" />
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-8 w-8 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.
|
||||
<h3 className="text-lg font-semibold text-green-500">Deposit Successful!</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{amount} {token} added to your P2P 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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,8 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Zap, ArrowRight, Shield, Clock, Star, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat';
|
||||
import type { CryptoToken, FiatCurrency } from '@/lib/p2p-fiat';
|
||||
|
||||
interface BestOffer {
|
||||
id: string;
|
||||
@@ -68,7 +67,6 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Calculate conversion
|
||||
const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || '';
|
||||
@@ -172,12 +170,7 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
||||
}
|
||||
|
||||
toast.success('Express trade started!');
|
||||
|
||||
if (onTradeStarted) {
|
||||
onTradeStarted(response.trade_id);
|
||||
} else {
|
||||
navigate(`/p2p/trade/${response.trade_id}`);
|
||||
}
|
||||
onTradeStarted?.(response.trade_id);
|
||||
} catch (err) {
|
||||
console.error('Express trade error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start trade');
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Lock,
|
||||
Unlock
|
||||
} from 'lucide-react';
|
||||
import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat';
|
||||
import { getInternalBalances, type InternalBalance } from '@/lib/p2p-fiat';
|
||||
|
||||
interface InternalBalanceCardProps {
|
||||
onDeposit?: () => void;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Clock, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
|
||||
import { getUserTrades, type P2PFiatTrade } from '@/lib/p2p-fiat';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface MyTradesProps {
|
||||
onTradeSelect: (tradeId: string) => void;
|
||||
}
|
||||
|
||||
export function MyTrades({ onTradeSelect }: MyTradesProps) {
|
||||
const [trades, setTrades] = useState<P2PFiatTrade[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrades = async () => {
|
||||
if (!user) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserTrades(user.id);
|
||||
setTrades(data);
|
||||
} catch (error) {
|
||||
console.error('Fetch trades error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTrades();
|
||||
}, [user]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="outline" className="text-yellow-400 border-yellow-400"><Clock className="w-3 h-3 mr-1" />Pending</Badge>;
|
||||
case 'payment_sent':
|
||||
return <Badge variant="outline" className="text-blue-400 border-blue-400"><Clock className="w-3 h-3 mr-1" />Payment Sent</Badge>;
|
||||
case 'completed':
|
||||
return <Badge variant="outline" className="text-green-400 border-green-400"><CheckCircle2 className="w-3 h-3 mr-1" />Completed</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="outline" className="text-gray-400 border-gray-400"><XCircle className="w-3 h-3 mr-1" />Cancelled</Badge>;
|
||||
case 'disputed':
|
||||
return <Badge variant="outline" className="text-red-400 border-red-400"><AlertTriangle className="w-3 h-3 mr-1" />Disputed</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trades.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
<p>No trades yet</p>
|
||||
<p className="text-sm mt-1">Start by accepting an offer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
{trades.map((trade) => (
|
||||
<Card
|
||||
key={trade.id}
|
||||
className="bg-card cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onTradeSelect(trade.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold">
|
||||
{trade.crypto_amount.toFixed(4)} HEZ
|
||||
</span>
|
||||
{getStatusBadge(trade.status)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>${trade.fiat_amount.toFixed(2)}</span>
|
||||
<span>{formatDistanceToNow(new Date(trade.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{user?.id === trade.seller_id ? 'Selling' : 'Buying'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,14 +36,12 @@ interface Notification {
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch notifications
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -67,12 +64,10 @@ export function NotificationBell() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -90,6 +85,8 @@ export function NotificationBell() {
|
||||
const newNotif = payload.new as Notification;
|
||||
setNotifications(prev => [newNotif, ...prev.slice(0, 19)]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
// Haptic feedback
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
@@ -99,7 +96,6 @@ export function NotificationBell() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Mark as read
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await supabase
|
||||
@@ -116,7 +112,6 @@ export function NotificationBell() {
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const markAllAsRead = async () => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -134,21 +129,13 @@ export function NotificationBell() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification click
|
||||
const handleClick = (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Navigate to reference
|
||||
if (notification.reference_type === 'trade' && notification.reference_id) {
|
||||
navigate(`/p2p/trade/${notification.reference_id}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Get icon for notification type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
@@ -170,7 +157,6 @@ export function NotificationBell() {
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (seconds < 60) return 'Just now';
|
||||
@@ -187,11 +173,7 @@ export function NotificationBell() {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-gray-400 hover:text-white"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-red-500 text-white text-xs rounded-full">
|
||||
@@ -201,33 +183,30 @@ export function NotificationBell() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 bg-gray-900 border-gray-800"
|
||||
>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span className="text-white">Notifications</span>
|
||||
<span>Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-gray-400 hover:text-white h-auto py-1"
|
||||
className="text-xs h-auto py-1"
|
||||
>
|
||||
<CheckCheck className="w-3 h-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Bell className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
@@ -236,23 +215,19 @@ export function NotificationBell() {
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
onClick={() => handleClick(notification)}
|
||||
className={`
|
||||
flex items-start gap-3 p-3 cursor-pointer
|
||||
${!notification.is_read ? 'bg-gray-800/50' : ''}
|
||||
hover:bg-gray-800
|
||||
`}
|
||||
className={`flex items-start gap-3 p-3 cursor-pointer ${!notification.is_read ? 'bg-accent/50' : ''}`}
|
||||
>
|
||||
<div className="mt-0.5">{getIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'text-white font-medium' : 'text-gray-300'}`}>
|
||||
<p className={`text-sm ${!notification.is_read ? 'font-medium' : ''}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -263,21 +238,6 @@ export function NotificationBell() {
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate('/p2p/orders');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="justify-center text-gray-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
View all trades
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
+217
-199
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks } from 'lucide-react';
|
||||
import {
|
||||
PlusCircle, ClipboardList, TrendingUp, CheckCircle2, Clock,
|
||||
ArrowLeft, Zap, Blocks, Wallet, ArrowDownToLine, ArrowUpFromLine
|
||||
} from 'lucide-react';
|
||||
import { AdList } from './AdList';
|
||||
import { CreateAd } from './CreateAd';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
@@ -14,10 +16,14 @@ import { DepositModal } from './DepositModal';
|
||||
import { WithdrawModal } from './WithdrawModal';
|
||||
import { ExpressMode } from './ExpressMode';
|
||||
import { BlockTrade } from './BlockTrade';
|
||||
import { MyTrades } from './MyTrades';
|
||||
import { TradeDetail } from './TradeDetail';
|
||||
import { DEFAULT_FILTERS, type P2PFilters } from './types';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
type View = 'main' | 'create-ad' | 'my-trades' | 'trade-detail';
|
||||
|
||||
interface UserStats {
|
||||
activeTrades: number;
|
||||
completedTrades: number;
|
||||
@@ -25,40 +31,42 @@ interface UserStats {
|
||||
}
|
||||
|
||||
export function P2PDashboard() {
|
||||
const [showCreateAd, setShowCreateAd] = useState(false);
|
||||
const [view, setView] = useState<View>('main');
|
||||
const [selectedTradeId, setSelectedTradeId] = useState<string | null>(null);
|
||||
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 { user, isLoading, error, login } = useAuth();
|
||||
|
||||
const handleBalanceUpdated = () => {
|
||||
setBalanceRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleTradeStarted = (tradeId: string) => {
|
||||
setSelectedTradeId(tradeId);
|
||||
setView('trade-detail');
|
||||
};
|
||||
|
||||
// Fetch user stats
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Count active trades
|
||||
const { count: activeCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.in('status', ['pending', 'payment_sent']);
|
||||
|
||||
// Count completed trades
|
||||
const { count: completedCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.eq('status', 'completed');
|
||||
|
||||
// Calculate total volume
|
||||
const { data: trades } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('fiat_amount')
|
||||
@@ -72,219 +80,229 @@ export function P2PDashboard() {
|
||||
completedTrades: completedCount || 0,
|
||||
totalVolume,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch stats error:', error);
|
||||
} catch (err) {
|
||||
console.error('Fetch stats error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [user]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Connecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-4">
|
||||
<div className="text-center max-w-sm">
|
||||
<Wallet className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">P2P Trading</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Trade crypto with local currency securely via Telegram.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm mb-4">{error}</p>
|
||||
)}
|
||||
<Button onClick={login} size="lg" className="w-full">
|
||||
Login with Telegram
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Trade detail view
|
||||
if (view === 'trade-detail' && selectedTradeId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 bg-background/95 backdrop-blur border-b border-border p-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setView('my-trades')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</header>
|
||||
<TradeDetail tradeId={selectedTradeId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// My trades view
|
||||
if (view === 'my-trades') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 bg-background/95 backdrop-blur border-b border-border p-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setView('main')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<h1 className="font-semibold">My Trades</h1>
|
||||
<div className="w-16" />
|
||||
</header>
|
||||
<MyTrades onTradeSelect={(id) => {
|
||||
setSelectedTradeId(id);
|
||||
setView('trade-detail');
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create ad view
|
||||
if (view === 'create-ad') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 bg-background/95 backdrop-blur border-b border-border p-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setView('main')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<h1 className="font-semibold">Create Ad</h1>
|
||||
<div className="w-16" />
|
||||
</header>
|
||||
<CreateAd onAdCreated={() => setView('main')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main dashboard view
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 bg-background/95 backdrop-blur border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">P2P Trading</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setView('my-trades')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
{userStats.activeTrades > 0 && (
|
||||
<Badge className="ml-1 bg-yellow-500 text-black text-xs">
|
||||
{userStats.activeTrades}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Balance Card */}
|
||||
<InternalBalanceCard
|
||||
key={balanceRefreshKey}
|
||||
onDeposit={() => setShowDepositModal(true)}
|
||||
onWithdraw={() => setShowWithdrawModal(true)}
|
||||
/>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Card className="bg-card">
|
||||
<CardContent className="p-3 text-center">
|
||||
<Clock className="w-4 h-4 text-yellow-400 mx-auto mb-1" />
|
||||
<p className="text-lg font-bold">{userStats.activeTrades}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Active</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card">
|
||||
<CardContent className="p-3 text-center">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400 mx-auto mb-1" />
|
||||
<p className="text-lg font-bold">{userStats.completedTrades}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card">
|
||||
<CardContent className="p-3 text-center">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400 mx-auto mb-1" />
|
||||
<p className="text-lg font-bold">${userStats.totalVolume > 1000 ? `${(userStats.totalVolume / 1000).toFixed(1)}K` : userStats.totalVolume}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Volume</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/merchant')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
size="sm"
|
||||
className="h-auto py-3 flex-col"
|
||||
onClick={() => setShowDepositModal(true)}
|
||||
>
|
||||
<Store className="w-4 h-4 mr-2" />
|
||||
Merchant
|
||||
<ArrowDownToLine className="w-4 h-4 mb-1" />
|
||||
<span className="text-xs">Deposit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/orders')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
size="sm"
|
||||
className="h-auto py-3 flex-col"
|
||||
onClick={() => setShowWithdrawModal(true)}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
My Trades
|
||||
{userStats.activeTrades > 0 && (
|
||||
<Badge className="ml-2 bg-yellow-500 text-black">
|
||||
{userStats.activeTrades}
|
||||
</Badge>
|
||||
)}
|
||||
<ArrowUpFromLine className="w-4 h-4 mb-1" />
|
||||
<span className="text-xs">Withdraw</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-auto py-3 flex-col"
|
||||
onClick={() => setView('create-ad')}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mb-1" />
|
||||
<span className="text-xs">Post Ad</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<QuickFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{/* Main Tabs */}
|
||||
<Tabs defaultValue="buy" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 h-auto">
|
||||
<TabsTrigger value="express" className="text-xs py-2">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
Express
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="buy" className="text-xs py-2">Buy</TabsTrigger>
|
||||
<TabsTrigger value="sell" className="text-xs py-2">Sell</TabsTrigger>
|
||||
<TabsTrigger value="otc" className="text-xs py-2">
|
||||
<Blocks className="w-3 h-3 mr-1" />
|
||||
OTC
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="express" className="mt-4">
|
||||
<ExpressMode onTradeStarted={handleTradeStarted} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="buy" className="mt-4">
|
||||
<AdList type="buy" filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sell" className="mt-4">
|
||||
<AdList type="sell" filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="otc" className="mt-4">
|
||||
<BlockTrade />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards and Balance Card */}
|
||||
{user && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* Internal Balance Card - Takes more space */}
|
||||
<div className="lg:col-span-1">
|
||||
<InternalBalanceCard
|
||||
key={balanceRefreshKey}
|
||||
onDeposit={() => setShowDepositModal(true)}
|
||||
onWithdraw={() => setShowWithdrawModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.activeTrades}</p>
|
||||
<p className="text-sm text-gray-400">Active Trades</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.completedTrades}</p>
|
||||
<p className="text-sm text-gray-400">Completed</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">${userStats.totalVolume.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-400">Volume</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white">P2P Trading</h1>
|
||||
<p className="text-gray-400">Buy and sell crypto with your local currency.</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateAd(true)}>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Post a New Ad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateAd ? (
|
||||
<CreateAd onAdCreated={() => setShowCreateAd(false)} />
|
||||
) : (
|
||||
<>
|
||||
{/* Quick Filter Bar */}
|
||||
<QuickFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
<Tabs defaultValue="buy">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="express" className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Express
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="buy">Buy</TabsTrigger>
|
||||
<TabsTrigger value="sell">Sell</TabsTrigger>
|
||||
<TabsTrigger value="my-ads">My Ads</TabsTrigger>
|
||||
<TabsTrigger value="otc" className="flex items-center gap-1">
|
||||
<Blocks className="w-3 h-3" />
|
||||
OTC
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="express">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||
<ExpressMode onTradeStarted={(id) => navigate(`/p2p/trade/${id}`)} />
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Why Express Mode?</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Instant best-rate matching
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Verified merchants only
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
Escrow protection
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
No manual offer selection
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="buy">
|
||||
<AdList type="buy" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sell">
|
||||
<AdList type="sell" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="my-ads">
|
||||
<AdList type="my-ads" filters={filters} />
|
||||
</TabsContent>
|
||||
<TabsContent value="otc">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||
<BlockTrade />
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Block Trade Benefits</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Custom pricing negotiation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Dedicated OTC desk support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Multi-tranche settlements
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Enhanced privacy
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400" />
|
||||
Flexible payment terms
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deposit Modal */}
|
||||
{/* Modals */}
|
||||
<DepositModal
|
||||
isOpen={showDepositModal}
|
||||
onClose={() => setShowDepositModal(false)}
|
||||
onSuccess={handleBalanceUpdated}
|
||||
/>
|
||||
|
||||
{/* Withdraw Modal */}
|
||||
<WithdrawModal
|
||||
isOpen={showWithdrawModal}
|
||||
onClose={() => setShowWithdrawModal(false)}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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 { Clock, CheckCircle2, XCircle, AlertTriangle, Copy, Check } from 'lucide-react';
|
||||
import { getTradeById, markPaymentSent, confirmPaymentReceived, cancelTrade, type P2PFiatTrade } from '@/lib/p2p-fiat';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TradeDetailProps {
|
||||
tradeId: string;
|
||||
}
|
||||
|
||||
export function TradeDetail({ tradeId }: TradeDetailProps) {
|
||||
const [trade, setTrade] = useState<P2PFiatTrade | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
const fetchTrade = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getTradeById(tradeId);
|
||||
setTrade(data);
|
||||
} catch (error) {
|
||||
console.error('Fetch trade error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrade();
|
||||
// Poll for updates
|
||||
const interval = setInterval(fetchTrade, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [tradeId]);
|
||||
|
||||
const handleMarkPaid = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await markPaymentSent(tradeId);
|
||||
await fetchTrade();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await confirmPaymentReceived(tradeId);
|
||||
await fetchTrade();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Are you sure you want to cancel this trade?')) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await cancelTrade(tradeId);
|
||||
await fetchTrade();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trade) {
|
||||
return (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
Trade not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSeller = user?.id === trade.seller_id;
|
||||
const isBuyer = user?.id === trade.buyer_id;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (trade.status) {
|
||||
case 'pending':
|
||||
return <Badge className="bg-yellow-500"><Clock className="w-3 h-3 mr-1" />Waiting for Payment</Badge>;
|
||||
case 'payment_sent':
|
||||
return <Badge className="bg-blue-500"><Clock className="w-3 h-3 mr-1" />Payment Sent</Badge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500"><CheckCircle2 className="w-3 h-3 mr-1" />Completed</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="secondary"><XCircle className="w-3 h-3 mr-1" />Cancelled</Badge>;
|
||||
case 'disputed':
|
||||
return <Badge variant="destructive"><AlertTriangle className="w-3 h-3 mr-1" />Disputed</Badge>;
|
||||
default:
|
||||
return <Badge>{trade.status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Trade Status</CardTitle>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Amount</span>
|
||||
<span className="font-semibold">{trade.crypto_amount.toFixed(4)} HEZ</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span className="font-semibold">${trade.fiat_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Rate</span>
|
||||
<span>${trade.price_per_unit.toFixed(4)}/HEZ</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDistanceToNow(new Date(trade.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
{trade.payment_deadline && trade.status === 'pending' && (
|
||||
<div className="flex justify-between text-yellow-400">
|
||||
<span>Payment Deadline</span>
|
||||
<span>{formatDistanceToNow(new Date(trade.payment_deadline), { addSuffix: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role-specific info */}
|
||||
{isBuyer && trade.status === 'pending' && (
|
||||
<Card className="bg-card border-yellow-500/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Payment Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send the payment to the seller and mark as paid when done.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleMarkPaid}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? 'Processing...' : 'I Have Paid'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isSeller && trade.status === 'payment_sent' && (
|
||||
<Card className="bg-card border-blue-500/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Confirm Payment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Buyer has marked payment as sent. Check your account and confirm when received.
|
||||
</p>
|
||||
{trade.buyer_payment_proof_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => window.open(trade.buyer_payment_proof_url, '_blank')}
|
||||
>
|
||||
View Payment Proof
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
onClick={handleConfirm}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? 'Processing...' : 'Confirm Payment Received'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{trade.status === 'completed' && (
|
||||
<Card className="bg-card border-green-500/50">
|
||||
<CardContent className="py-6 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<p className="font-semibold text-green-400">Trade Completed!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isBuyer ? 'HEZ has been credited to your balance.' : 'Payment has been received.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Trade ID */}
|
||||
<Card className="bg-card">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Trade ID</span>
|
||||
<button
|
||||
className="flex items-center gap-1 text-sm font-mono"
|
||||
onClick={() => copyToClipboard(trade.id)}
|
||||
>
|
||||
{trade.id.slice(0, 8)}...
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,37 +13,33 @@ import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||
import { acceptFiatOffer, type P2PFiatOffer } from '@/lib/p2p-fiat';
|
||||
|
||||
interface TradeModalProps {
|
||||
offer: P2PFiatOffer;
|
||||
onClose: () => void;
|
||||
onTradeStarted?: (tradeId: string) => void;
|
||||
}
|
||||
|
||||
export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
const navigate = useNavigate();
|
||||
export function TradeModal({ offer, onClose, onTradeStarted }: TradeModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [amount, setAmount] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const cryptoAmount = parseFloat(amount) || 0;
|
||||
const fiatAmount = cryptoAmount * offer.price_per_unit;
|
||||
const isValidAmount = cryptoAmount > 0 && cryptoAmount <= offer.remaining_amount;
|
||||
|
||||
// Check min/max order amounts
|
||||
|
||||
const meetsMinOrder = !offer.min_order_amount || cryptoAmount >= offer.min_order_amount;
|
||||
const meetsMaxOrder = !offer.max_order_amount || cryptoAmount <= offer.max_order_amount;
|
||||
|
||||
const handleInitiateTrade = async () => {
|
||||
if (!api || !selectedAccount || !user) {
|
||||
toast.error('Please connect your wallet and log in');
|
||||
if (!user) {
|
||||
toast.error('Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent self-trading
|
||||
if (offer.seller_id === user.id) {
|
||||
toast.error('You cannot trade with your own offer');
|
||||
return;
|
||||
@@ -69,20 +64,16 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
|
||||
try {
|
||||
const tradeId = await acceptFiatOffer({
|
||||
api,
|
||||
account: selectedAccount,
|
||||
offerId: offer.id,
|
||||
buyerWallet: user.wallet_address || '',
|
||||
amount: cryptoAmount
|
||||
});
|
||||
|
||||
toast.success('Trade initiated! Proceed to payment.');
|
||||
onClose();
|
||||
|
||||
// Navigate to trade page
|
||||
navigate(`/p2p/trade/${tradeId}`);
|
||||
onTradeStarted?.(tradeId);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Accept offer error:', error);
|
||||
// Error toast already shown in acceptFiatOffer
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -90,65 +81,57 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-md">
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Buy {offer.token}</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Trading with {offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)}
|
||||
<DialogDescription>
|
||||
Rate: {offer.price_per_unit.toFixed(4)} {offer.fiat_currency}/{offer.token}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Price Info */}
|
||||
<div className="p-4 bg-gray-800 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-400">Price</span>
|
||||
<span className="text-xl font-bold text-green-400">
|
||||
{offer.price_per_unit.toFixed(2)} {offer.fiat_currency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Available</span>
|
||||
<span className="text-white">{offer.remaining_amount} {offer.token}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<Label htmlFor="buyAmount">Amount to Buy ({offer.token})</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">Amount ({offer.token})</Label>
|
||||
<Input
|
||||
id="buyAmount"
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={`Max: ${offer.remaining_amount}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 placeholder:opacity-50"
|
||||
max={offer.remaining_amount}
|
||||
min={offer.min_order_amount || 0}
|
||||
step="0.0001"
|
||||
/>
|
||||
{offer.min_order_amount && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Min: {offer.min_order_amount} {offer.token}
|
||||
</p>
|
||||
)}
|
||||
{offer.max_order_amount && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Max: {offer.max_order_amount} {offer.token}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Available: {offer.remaining_amount.toFixed(4)} {offer.token}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Calculation */}
|
||||
{cryptoAmount > 0 && (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-1">You will pay</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{fiatAmount.toFixed(2)} {offer.fiat_currency}
|
||||
</p>
|
||||
<div className="p-3 bg-accent rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">You pay</span>
|
||||
<span className="font-medium">
|
||||
{fiatAmount.toFixed(2)} {offer.fiat_currency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-muted-foreground">You receive</span>
|
||||
<span className="font-medium">
|
||||
{cryptoAmount.toFixed(4)} {offer.token}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{!meetsMinOrder && cryptoAmount > 0 && (
|
||||
<Alert variant="default" className="border-yellow-500/50">
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Payment must be completed within {offer.time_limit_minutes} minutes
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{!meetsMinOrder && offer.min_order_amount && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
@@ -157,7 +140,7 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!meetsMaxOrder && cryptoAmount > 0 && (
|
||||
{!meetsMaxOrder && offer.max_order_amount && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
@@ -165,37 +148,18 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Payment Time Limit */}
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Payment deadline: {offer.time_limit_minutes} minutes after accepting
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="bg-gray-800 border-gray-700 hover:bg-gray-700"
|
||||
>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleInitiateTrade}
|
||||
disabled={!isValidAmount || !meetsMinOrder || !meetsMaxOrder || loading}
|
||||
disabled={loading || !isValidAmount || !meetsMinOrder || !meetsMaxOrder}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Initiating...
|
||||
</>
|
||||
) : (
|
||||
'Accept & Continue'
|
||||
)}
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{loading ? 'Processing...' : 'Start Trade'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Withdraw Modal - Mobile P2P
|
||||
*
|
||||
* Request withdrawal from internal P2P balance to external wallet.
|
||||
* Backend processes the actual blockchain transaction.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,26 +24,20 @@ import {
|
||||
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
|
||||
ArrowUpFromLine
|
||||
} from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
getInternalBalances,
|
||||
requestWithdraw,
|
||||
getDepositWithdrawHistory,
|
||||
getInternalBalance,
|
||||
type CryptoToken,
|
||||
type InternalBalance,
|
||||
type DepositWithdrawRequest
|
||||
} from '@shared/lib/p2p-fiat';
|
||||
type InternalBalance
|
||||
} from '@/lib/p2p-fiat';
|
||||
|
||||
interface WithdrawModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -45,60 +45,34 @@ interface WithdrawModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type WithdrawStep = 'form' | 'confirm' | 'success';
|
||||
|
||||
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
|
||||
const [step, setStep] = useState<WithdrawStep>('form');
|
||||
const { user } = useAuth();
|
||||
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>('');
|
||||
const [balance, setBalance] = useState<InternalBalance | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// 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);
|
||||
if (isOpen && user) {
|
||||
fetchBalance();
|
||||
// Pre-fill wallet from user profile
|
||||
if (user.wallet_address) {
|
||||
setWalletAddress(user.wallet_address);
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedAccount]);
|
||||
}, [isOpen, user, token]);
|
||||
|
||||
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 fetchBalance = async () => {
|
||||
const bal = await getInternalBalance(token);
|
||||
setBalance(bal);
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setStep('form');
|
||||
setAmount('');
|
||||
setSubmitting(false);
|
||||
setRequestId('');
|
||||
setLoading(false);
|
||||
setSuccess(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -106,102 +80,83 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
|
||||
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);
|
||||
const handleWithdraw = async () => {
|
||||
if (!walletAddress) {
|
||||
toast.error('Please enter wallet address');
|
||||
return;
|
||||
}
|
||||
|
||||
const withdrawAmount = parseFloat(amount);
|
||||
if (isNaN(withdrawAmount) || withdrawAmount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (balance && withdrawAmount > balance.available_balance) {
|
||||
toast.error('Insufficient balance');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await requestWithdraw(token, withdrawAmount, walletAddress);
|
||||
setSuccess(true);
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
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);
|
||||
if (balance) {
|
||||
setAmount(balance.available_balance.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormStep = () => (
|
||||
<div className="space-y-6">
|
||||
{loading ? (
|
||||
if (success) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="space-y-4 text-center py-4">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-green-500">Request Submitted!</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{amount} {token} withdrawal is being processed.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-2">
|
||||
Usually completes within 5-10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleClose} className="w-full">Done</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ArrowUpFromLine className="h-5 w-5" />
|
||||
Withdraw
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Withdraw from P2P balance to your wallet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<Label>Token</Label>
|
||||
<Select value={token} onValueChange={(v) => setToken(v as CryptoToken)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -213,245 +168,61 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
|
||||
</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"
|
||||
<div className="flex justify-between">
|
||||
<Label>Amount</Label>
|
||||
<button
|
||||
onClick={handleSetMax}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
MAX
|
||||
</Button>
|
||||
Max: {balance?.available_balance.toFixed(4) || '0'} {token}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min: {MIN_WITHDRAWAL} {token} | Max: {getMaxWithdrawable().toFixed(4)} {token}
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min="0"
|
||||
max={balance?.available_balance}
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Wallet Address */}
|
||||
<div className="space-y-2">
|
||||
<Label>Destination Wallet Address</Label>
|
||||
<Label>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>
|
||||
)}
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Withdrawals are processed by the platform. Network fee will be deducted.
|
||||
</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}>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!amount || parseFloat(amount) <= 0}
|
||||
onClick={handleWithdraw}
|
||||
disabled={loading || !amount || !walletAddress}
|
||||
className="flex-1"
|
||||
>
|
||||
Continue
|
||||
{loading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
|
||||
) : (
|
||||
'Withdraw'
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
+239
-270
@@ -1,309 +1,278 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge';
|
||||
|
||||
// Session timeout configuration
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
const LAST_ACTIVITY_KEY = 'last_activity_timestamp';
|
||||
const REMEMBER_ME_KEY = 'remember_me';
|
||||
// Telegram WebApp types
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp: {
|
||||
initData: string;
|
||||
initDataUnsafe: {
|
||||
user?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
photo_url?: string;
|
||||
};
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
};
|
||||
ready: () => void;
|
||||
expand: () => void;
|
||||
close: () => void;
|
||||
MainButton: {
|
||||
text: string;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onClick: (callback: () => void) => void;
|
||||
};
|
||||
HapticFeedback: {
|
||||
impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
|
||||
notificationOccurred: (type: 'error' | 'success' | 'warning') => void;
|
||||
selectionChanged: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string; // Supabase user ID
|
||||
telegram_id: number;
|
||||
telegram_username?: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
wallet_address?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
signIn: (email: string, password: string, rememberMe?: boolean) => Promise<{ error: Error | null }>;
|
||||
signUp: (email: string, password: string, username: string, referralCode?: string) => Promise<{ error: Error | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
checkAdminStatus: () => Promise<boolean>;
|
||||
telegramUser: TelegramUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
error: string | null;
|
||||
login: () => Promise<void>;
|
||||
logout: () => void;
|
||||
linkWallet: (address: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [telegramUser, setTelegramUser] = useState<TelegramUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ========================================
|
||||
// SESSION TIMEOUT MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
// Update last activity timestamp
|
||||
const updateLastActivity = useCallback(() => {
|
||||
|
||||
localStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
|
||||
// Get Telegram user from WebApp
|
||||
const getTelegramUser = useCallback((): TelegramUser | null => {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (!tg?.initDataUnsafe?.user) {
|
||||
return null;
|
||||
}
|
||||
return tg.initDataUnsafe.user;
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
setIsAdmin(false);
|
||||
setUser(null);
|
||||
localStorage.removeItem(LAST_ACTIVITY_KEY);
|
||||
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||
await supabase.auth.signOut();
|
||||
}, []);
|
||||
|
||||
// Check if session has timed out
|
||||
const checkSessionTimeout = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
// Skip timeout check if "Remember Me" is enabled
|
||||
const rememberMe = localStorage.getItem(REMEMBER_ME_KEY);
|
||||
if (rememberMe === 'true') {
|
||||
return; // Don't timeout if user chose to be remembered
|
||||
}
|
||||
|
||||
const lastActivity = localStorage.getItem(LAST_ACTIVITY_KEY);
|
||||
if (!lastActivity) {
|
||||
updateLastActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastActivityTime = parseInt(lastActivity, 10);
|
||||
const now = Date.now();
|
||||
const inactiveTime = now - lastActivityTime;
|
||||
|
||||
if (inactiveTime >= SESSION_TIMEOUT_MS) {
|
||||
if (import.meta.env.DEV) console.log('⏱️ Session timeout - logging out due to inactivity');
|
||||
await signOut();
|
||||
}
|
||||
}, [user, updateLastActivity, signOut]);
|
||||
|
||||
// Setup activity listeners
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Update activity on user interactions
|
||||
const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
|
||||
const handleActivity = () => {
|
||||
updateLastActivity();
|
||||
|
||||
|
||||
};
|
||||
|
||||
// Register event listeners
|
||||
activityEvents.forEach((event) => {
|
||||
window.addEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
// Initial activity timestamp
|
||||
updateLastActivity();
|
||||
|
||||
|
||||
|
||||
// Check for timeout periodically
|
||||
const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
activityEvents.forEach((event) => {
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
clearInterval(timeoutChecker);
|
||||
};
|
||||
}, [user, updateLastActivity, checkSessionTimeout]);
|
||||
|
||||
|
||||
const checkAdminStatus = useCallback(async () => {
|
||||
// Admin wallet whitelist (blockchain-based auth)
|
||||
const ADMIN_WALLETS = [
|
||||
'5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', // Founder (original)
|
||||
'5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3', // Founder delegate (initial KYC member)
|
||||
'5GgTgG9sRmPQAYU1RsTejZYnZRjwzKZKWD3awtuqjHioki45', // Founder (current dev wallet)
|
||||
];
|
||||
// Login with Telegram
|
||||
const login = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// PRIMARY: Check wallet-based admin (blockchain auth)
|
||||
const connectedWallet = localStorage.getItem('selectedWallet');
|
||||
if (import.meta.env.DEV) console.log('🔍 Admin check - Connected wallet:', connectedWallet);
|
||||
if (import.meta.env.DEV) console.log('🔍 Admin check - Whitelist:', ADMIN_WALLETS);
|
||||
const tg = window.Telegram?.WebApp;
|
||||
|
||||
if (connectedWallet && ADMIN_WALLETS.includes(connectedWallet)) {
|
||||
if (import.meta.env.DEV) console.log('✅ Admin access granted (wallet-based)');
|
||||
setIsAdmin(true);
|
||||
return true;
|
||||
if (!tg?.initData) {
|
||||
throw new Error('Telegram WebApp not available. Open from Telegram.');
|
||||
}
|
||||
|
||||
// SECONDARY: Check Supabase admin_roles (if wallet not in whitelist)
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
// Call Supabase Edge Function to verify initData and get/create user
|
||||
const { data, error: fnError } = await supabase.functions.invoke('telegram-auth', {
|
||||
body: { initData: tg.initData }
|
||||
});
|
||||
|
||||
if (!error && data && ['admin', 'super_admin'].includes(data.role)) {
|
||||
if (import.meta.env.DEV) console.log('✅ Admin access granted (Supabase-based)');
|
||||
setIsAdmin(true);
|
||||
return true;
|
||||
}
|
||||
if (fnError) throw fnError;
|
||||
|
||||
if (!data?.user) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log('❌ Admin access denied');
|
||||
setIsAdmin(false);
|
||||
return false;
|
||||
setUser(data.user);
|
||||
setTelegramUser(getTelegramUser());
|
||||
|
||||
// Store session token if provided
|
||||
if (data.session_token) {
|
||||
localStorage.setItem('p2p_session', data.session_token);
|
||||
}
|
||||
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Admin check error:', err);
|
||||
setIsAdmin(false);
|
||||
const message = err instanceof Error ? err.message : 'Login failed';
|
||||
setError(message);
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('error');
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [getTelegramUser]);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('p2p_session');
|
||||
window.Telegram?.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
}, []);
|
||||
|
||||
// Link wallet address
|
||||
const linkWallet = useCallback(async (address: string) => {
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('p2p_users')
|
||||
.update({ wallet_address: address })
|
||||
.eq('telegram_id', user.telegram_id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
setUser(prev => prev ? { ...prev, wallet_address: address } : null);
|
||||
window.Telegram?.WebApp.HapticFeedback.notificationOccurred('success');
|
||||
}, [user]);
|
||||
|
||||
// Login via URL params (from mini-app redirect)
|
||||
const loginViaParams = useCallback(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tgId = params.get('tg_id');
|
||||
const wallet = params.get('wallet');
|
||||
const from = params.get('from');
|
||||
const ts = params.get('ts');
|
||||
|
||||
if (!tgId || from !== 'miniapp') {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Setup native mobile wallet if running in mobile app
|
||||
const setupMobileWallet = useCallback(() => {
|
||||
if (isMobileApp()) {
|
||||
const nativeAddress = getNativeWalletAddress();
|
||||
const nativeAccountName = getNativeAccountName();
|
||||
|
||||
if (nativeAddress) {
|
||||
// Store native wallet address for admin checks and wallet operations
|
||||
localStorage.setItem('selectedWallet', nativeAddress);
|
||||
if (nativeAccountName) {
|
||||
localStorage.setItem('selectedWalletName', nativeAccountName);
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native wallet detected:', nativeAddress);
|
||||
}
|
||||
// Dispatch wallet change event
|
||||
window.dispatchEvent(new Event('walletChanged'));
|
||||
// Validate timestamp (not older than 5 minutes)
|
||||
if (ts) {
|
||||
const timestamp = parseInt(ts);
|
||||
const now = Date.now();
|
||||
if (now - timestamp > 5 * 60 * 1000) {
|
||||
console.warn('URL params expired');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Verify with backend and get/create user
|
||||
const { data, error: fnError } = await supabase.functions.invoke('telegram-auth', {
|
||||
body: {
|
||||
telegram_id: parseInt(tgId),
|
||||
wallet_address: wallet || undefined,
|
||||
from_miniapp: true
|
||||
}
|
||||
});
|
||||
|
||||
if (fnError) throw fnError;
|
||||
|
||||
if (!data?.user) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
|
||||
// Store session token
|
||||
if (data.session_token) {
|
||||
localStorage.setItem('p2p_session', data.session_token);
|
||||
}
|
||||
|
||||
// Clear URL params after successful login
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('URL param login error:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-login on mount
|
||||
useEffect(() => {
|
||||
// Setup mobile wallet first
|
||||
setupMobileWallet();
|
||||
const initAuth = async () => {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
|
||||
// Check active sessions and sets the user
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setUser(session?.user ?? null);
|
||||
checkAdminStatus(); // Check admin status regardless of Supabase session
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
// If Supabase is not available, still check wallet-based admin
|
||||
checkAdminStatus();
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for changes on auth state
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
checkAdminStatus(); // Check admin status on auth change
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for wallet changes (from PezkuwiContext or native bridge)
|
||||
const handleWalletChange = () => {
|
||||
checkAdminStatus();
|
||||
};
|
||||
window.addEventListener('walletChanged', handleWalletChange);
|
||||
|
||||
// Listen for native bridge ready event (mobile app)
|
||||
const handleNativeReady = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native bridge ready');
|
||||
}
|
||||
setupMobileWallet();
|
||||
checkAdminStatus();
|
||||
};
|
||||
window.addEventListener('pezkuwi-native-ready', handleNativeReady);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
window.removeEventListener('walletChanged', handleWalletChange);
|
||||
window.removeEventListener('pezkuwi-native-ready', handleNativeReady);
|
||||
};
|
||||
}, [checkAdminStatus, setupMobileWallet]);
|
||||
|
||||
const signIn = async (email: string, password: string, rememberMe: boolean = false) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!error && data.user) {
|
||||
// Store remember me preference
|
||||
if (rememberMe) {
|
||||
localStorage.setItem(REMEMBER_ME_KEY, 'true');
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||
}
|
||||
await checkAdminStatus();
|
||||
}
|
||||
|
||||
return { error };
|
||||
} catch {
|
||||
return {
|
||||
error: {
|
||||
message: 'Authentication service unavailable. Please try again later.'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string, username: string, referralCode?: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username,
|
||||
referral_code: referralCode || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!error && data.user) {
|
||||
// Create profile in profiles table with referral code
|
||||
await supabase.from('profiles').insert({
|
||||
id: data.user.id,
|
||||
username,
|
||||
email,
|
||||
referred_by: referralCode || null,
|
||||
});
|
||||
|
||||
// If there's a referral code, track it
|
||||
if (referralCode) {
|
||||
// You can add logic here to reward the referrer
|
||||
// For example, update their referral count or add rewards
|
||||
if (import.meta.env.DEV) console.log(`User registered with referral code: ${referralCode}`);
|
||||
// Check for existing session first
|
||||
const sessionToken = localStorage.getItem('p2p_session');
|
||||
if (sessionToken) {
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('telegram-auth', {
|
||||
body: { sessionToken }
|
||||
});
|
||||
if (!error && data?.user) {
|
||||
setUser(data.user);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('p2p_session');
|
||||
}
|
||||
}
|
||||
|
||||
return { error };
|
||||
} catch {
|
||||
return {
|
||||
error: {
|
||||
message: 'Registration service unavailable. Please try again later.'
|
||||
}
|
||||
};
|
||||
}
|
||||
// Try Telegram WebApp auth
|
||||
if (tg?.initData) {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
setTelegramUser(getTelegramUser());
|
||||
await login();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try URL params auth (from mini-app redirect)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('from') === 'miniapp' && params.get('tg_id')) {
|
||||
const success = await loginViaParams();
|
||||
if (success) return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, [getTelegramUser, login, loginViaParams]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
telegramUser,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
linkWallet
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
isAdmin,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
checkAdminStatus
|
||||
}}>
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { getAllTikiNFTDetails, generateCitizenNumber, type TikiNFTDetails } from '@pezkuwi/lib/tiki';
|
||||
import { getKycStatus } from '@pezkuwi/lib/kyc';
|
||||
|
||||
interface DashboardData {
|
||||
profile: Record<string, unknown> | null | null;
|
||||
nftDetails: { citizenNFT: TikiNFTDetails | null; roleNFTs: TikiNFTDetails[]; totalNFTs: number };
|
||||
kycStatus: string;
|
||||
citizenNumber: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const DashboardContext = createContext<DashboardData | undefined>(undefined);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
||||
const [nftDetails, setNftDetails] = useState<{ citizenNFT: TikiNFTDetails | null; roleNFTs: TikiNFTDetails[]; totalNFTs: number }>({
|
||||
citizenNFT: null,
|
||||
roleNFTs: [],
|
||||
totalNFTs: 0
|
||||
});
|
||||
const [kycStatus, setKycStatus] = useState<string>('NotStarted');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchProfile = useCallback(async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (import.meta.env.DEV) console.warn('Profile fetch error (this is normal if Supabase is not configured):', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setProfile(data);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.warn('Error fetching profile (this is normal if Supabase is not configured):', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchScoresAndTikis = useCallback(async () => {
|
||||
if (!selectedAccount || !api) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const status = await getKycStatus(api, selectedAccount.address);
|
||||
setKycStatus(status);
|
||||
|
||||
const details = await getAllTikiNFTDetails(api, selectedAccount.address);
|
||||
setNftDetails(details);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAccount, api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchScoresAndTikis();
|
||||
|
||||
}
|
||||
}, [user, selectedAccount, api, isApiReady, fetchProfile, fetchScoresAndTikis]);
|
||||
|
||||
const citizenNumber = nftDetails.citizenNFT
|
||||
? generateCitizenNumber(nftDetails.citizenNFT.owner, nftDetails.citizenNFT.collectionId, nftDetails.citizenNFT.itemId)
|
||||
: 'N/A';
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider value={{ profile, nftDetails, kycStatus, citizenNumber, loading }}>
|
||||
{children}
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const context = useContext(DashboardContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useDashboard must be used within a DashboardProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
import { web3Accounts, web3Enable } from '@pezkuwi/extension-dapp';
|
||||
import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
|
||||
import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/pezkuwi';
|
||||
import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge';
|
||||
|
||||
interface PezkuwiContextType {
|
||||
api: ApiPromise | null;
|
||||
isApiReady: boolean;
|
||||
isConnected: boolean;
|
||||
accounts: InjectedAccountWithMeta[];
|
||||
selectedAccount: InjectedAccountWithMeta | null;
|
||||
setSelectedAccount: (account: InjectedAccountWithMeta | null) => void;
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnectWallet: () => void;
|
||||
error: string | null;
|
||||
sudoKey: string | null;
|
||||
}
|
||||
|
||||
const PezkuwiContext = createContext<PezkuwiContextType | undefined>(undefined);
|
||||
|
||||
interface PezkuwiProviderProps {
|
||||
children: ReactNode;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
children,
|
||||
endpoint = DEFAULT_ENDPOINT // Beta testnet RPC from shared config
|
||||
}) => {
|
||||
const [api, setApi] = useState<ApiPromise | null>(null);
|
||||
const [isApiReady, setIsApiReady] = useState(false);
|
||||
const [accounts, setAccounts] = useState<InjectedAccountWithMeta[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<InjectedAccountWithMeta | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sudoKey, setSudoKey] = useState<string | null>(null);
|
||||
|
||||
// Wrapper to trigger events when wallet changes
|
||||
const handleSetSelectedAccount = (account: InjectedAccountWithMeta | null) => {
|
||||
setSelectedAccount(account);
|
||||
if (account) {
|
||||
localStorage.setItem('selectedWallet', account.address);
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.log('💾 Wallet saved:', account.address);
|
||||
}
|
||||
window.dispatchEvent(new Event('walletChanged'));
|
||||
} else {
|
||||
localStorage.removeItem('selectedWallet');
|
||||
window.dispatchEvent(new Event('walletChanged'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Pezkuwi API with fallback endpoints
|
||||
useEffect(() => {
|
||||
const FALLBACK_ENDPOINTS = [
|
||||
endpoint,
|
||||
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_1,
|
||||
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_2,
|
||||
].filter(Boolean);
|
||||
|
||||
const initApi = async () => {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const currentEndpoint of FALLBACK_ENDPOINTS) {
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.log('🔗 Connecting to Pezkuwi node:', currentEndpoint);
|
||||
}
|
||||
|
||||
const provider = new WsProvider(currentEndpoint);
|
||||
const apiInstance = await ApiPromise.create({ provider });
|
||||
|
||||
await apiInstance.isReady;
|
||||
|
||||
setApi(apiInstance);
|
||||
setIsApiReady(true);
|
||||
setError(null);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.log('✅ Connected to Pezkuwi node');
|
||||
|
||||
// Get chain info
|
||||
const [chain, nodeName, nodeVersion] = await Promise.all([
|
||||
apiInstance.rpc.system.chain(),
|
||||
apiInstance.rpc.system.name(),
|
||||
apiInstance.rpc.system.version(),
|
||||
]);
|
||||
|
||||
if (import.meta.env.DEV) console.log(`📡 Chain: ${chain}`);
|
||||
if (import.meta.env.DEV) console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`);
|
||||
}
|
||||
|
||||
// Fetch sudo key from blockchain
|
||||
try {
|
||||
const sudoAccount = await apiInstance.query.sudo.key();
|
||||
const sudoAddress = sudoAccount.toString();
|
||||
setSudoKey(sudoAddress);
|
||||
if (import.meta.env.DEV) console.log(`🔑 Sudo key: ${sudoAddress}`);
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('⚠️ Failed to fetch sudo key (sudo pallet may not be available):', err);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.warn(`⚠️ Failed to connect to ${currentEndpoint}, trying next...`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.error('❌ Failed to connect to all endpoints:', lastError);
|
||||
}
|
||||
setError('Failed to connect to blockchain network. Please try again later.');
|
||||
setIsApiReady(false);
|
||||
};
|
||||
|
||||
initApi();
|
||||
|
||||
return () => {
|
||||
if (api) {
|
||||
api.disconnect();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint]);
|
||||
|
||||
// Auto-restore wallet on page load (or setup mobile wallet)
|
||||
useEffect(() => {
|
||||
const restoreWallet = async () => {
|
||||
// Check if running in mobile app
|
||||
if (isMobileApp()) {
|
||||
const nativeAddress = getNativeWalletAddress();
|
||||
const nativeAccountName = getNativeAccountName();
|
||||
|
||||
if (nativeAddress) {
|
||||
// Create a virtual account for the mobile wallet
|
||||
const mobileAccount: InjectedAccountWithMeta = {
|
||||
address: nativeAddress,
|
||||
meta: {
|
||||
name: nativeAccountName || 'Mobile Wallet',
|
||||
source: 'pezkuwi-mobile',
|
||||
},
|
||||
type: 'sr25519',
|
||||
};
|
||||
|
||||
setAccounts([mobileAccount]);
|
||||
handleSetSelectedAccount(mobileAccount);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native wallet connected:', nativeAddress.slice(0, 8) + '...');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Try to restore from localStorage
|
||||
const savedAddress = localStorage.getItem('selectedWallet');
|
||||
if (!savedAddress) return;
|
||||
|
||||
try {
|
||||
// Enable extension
|
||||
const extensions = await web3Enable('PezkuwiChain');
|
||||
if (extensions.length === 0) return;
|
||||
|
||||
// Get accounts
|
||||
const allAccounts = await web3Accounts();
|
||||
if (allAccounts.length === 0) return;
|
||||
|
||||
// Find saved account
|
||||
const savedAccount = allAccounts.find(acc => acc.address === savedAddress);
|
||||
if (savedAccount) {
|
||||
setAccounts(allAccounts);
|
||||
handleSetSelectedAccount(savedAccount);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Wallet restored:', savedAddress.slice(0, 8) + '...');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Failed to restore wallet:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
restoreWallet();
|
||||
|
||||
// Listen for native bridge ready event (mobile)
|
||||
const handleNativeReady = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native bridge ready, restoring wallet');
|
||||
}
|
||||
restoreWallet();
|
||||
};
|
||||
|
||||
window.addEventListener('pezkuwi-native-ready', handleNativeReady);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pezkuwi-native-ready', handleNativeReady);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Connect wallet (Pezkuwi.js extension or native mobile)
|
||||
const connectWallet = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Check if running in mobile app
|
||||
if (isMobileApp()) {
|
||||
const nativeAddress = getNativeWalletAddress();
|
||||
const nativeAccountName = getNativeAccountName();
|
||||
|
||||
if (nativeAddress) {
|
||||
// Create a virtual account for the mobile wallet
|
||||
const mobileAccount: InjectedAccountWithMeta = {
|
||||
address: nativeAddress,
|
||||
meta: {
|
||||
name: nativeAccountName || 'Mobile Wallet',
|
||||
source: 'pezkuwi-mobile',
|
||||
},
|
||||
type: 'sr25519',
|
||||
};
|
||||
|
||||
setAccounts([mobileAccount]);
|
||||
handleSetSelectedAccount(mobileAccount);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native wallet connected:', nativeAddress.slice(0, 8) + '...');
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Request wallet connection from native app
|
||||
setError('Please connect your wallet in the app');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Check if extension is installed first
|
||||
const hasExtension = !!(window as unknown as { injectedWeb3?: Record<string, unknown> }).injectedWeb3;
|
||||
|
||||
// Enable extension
|
||||
const extensions = await web3Enable('PezkuwiChain');
|
||||
|
||||
if (extensions.length === 0) {
|
||||
if (hasExtension) {
|
||||
// Extension is installed but user didn't authorize - don't redirect
|
||||
setError('Please authorize the connection in your Pezkuwi Wallet extension');
|
||||
} else {
|
||||
// Extension not installed - show install link
|
||||
setError('Pezkuwi Wallet extension not found. Please install from Chrome Web Store.');
|
||||
window.open('https://chrome.google.com/webstore/detail/pezkuwi-wallet/fbnboicjjeebjhgnapneaeccpgjcdibn', '_blank');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Pezkuwi.js extension enabled');
|
||||
}
|
||||
|
||||
// Get accounts
|
||||
const allAccounts = await web3Accounts();
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
setError('No accounts found. Please create an account in Pezkuwi.js extension');
|
||||
return;
|
||||
}
|
||||
|
||||
setAccounts(allAccounts);
|
||||
|
||||
// Try to restore previously selected account, otherwise use first
|
||||
const savedAddress = localStorage.getItem('selectedWallet');
|
||||
const accountToSelect = savedAddress
|
||||
? allAccounts.find(acc => acc.address === savedAddress) || allAccounts[0]
|
||||
: allAccounts[0];
|
||||
|
||||
// Use wrapper to trigger events
|
||||
handleSetSelectedAccount(accountToSelect);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ Found ${allAccounts.length} account(s)`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('❌ Wallet connection failed:', err);
|
||||
}
|
||||
setError('Failed to connect wallet');
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnectWallet = () => {
|
||||
setAccounts([]);
|
||||
handleSetSelectedAccount(null);
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.log('🔌 Wallet disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
const value: PezkuwiContextType = {
|
||||
api,
|
||||
isApiReady,
|
||||
isConnected: isApiReady, // Alias for backward compatibility
|
||||
accounts,
|
||||
selectedAccount,
|
||||
setSelectedAccount: handleSetSelectedAccount,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
error,
|
||||
sudoKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<PezkuwiContext.Provider value={value}>
|
||||
{children}
|
||||
</PezkuwiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use Pezkuwi context
|
||||
export const usePezkuwi = (): PezkuwiContextType => {
|
||||
const context = useContext(PezkuwiContext);
|
||||
if (!context) {
|
||||
throw new Error('usePezkuwi must be used within PezkuwiProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
getReferralStats,
|
||||
getMyReferrals,
|
||||
initiateReferral,
|
||||
subscribeToReferralEvents,
|
||||
type ReferralStats,
|
||||
} from '@pezkuwi/lib/referral';
|
||||
|
||||
interface ReferralContextValue {
|
||||
stats: ReferralStats | null;
|
||||
myReferrals: string[];
|
||||
loading: boolean;
|
||||
inviteUser: (referredAddress: string) => Promise<boolean>;
|
||||
refreshStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ReferralContext = createContext<ReferralContextValue | undefined>(undefined);
|
||||
|
||||
export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const { account } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [stats, setStats] = useState<ReferralStats | null>(null);
|
||||
const [myReferrals, setMyReferrals] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch referral statistics
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!api || !isApiReady || !account) {
|
||||
setStats(null);
|
||||
setMyReferrals([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [fetchedStats, fetchedReferrals] = await Promise.all([
|
||||
getReferralStats(api, account),
|
||||
getMyReferrals(api, account),
|
||||
]);
|
||||
|
||||
setStats(fetchedStats);
|
||||
setMyReferrals(fetchedReferrals);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Error fetching referral stats:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load referral statistics',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, isApiReady, account, toast]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
// Subscribe to referral events for real-time updates
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
subscribeToReferralEvents(api, (event) => {
|
||||
// If this user is involved in the event, refresh stats
|
||||
if (event.referrer === account || event.referred === account) {
|
||||
if (event.type === 'initiated') {
|
||||
toast({
|
||||
title: 'Referral Sent',
|
||||
description: `Invitation sent to ${event.referred.slice(0, 8)}...`,
|
||||
});
|
||||
} else if (event.type === 'confirmed') {
|
||||
toast({
|
||||
title: 'Referral Confirmed!',
|
||||
description: `Your referral completed KYC. Total: ${event.count}`,
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
fetchStats();
|
||||
}
|
||||
}).then((unsubFn) => {
|
||||
unsub = unsubFn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unsub) unsub();
|
||||
};
|
||||
}, [api, isApiReady, account, toast, fetchStats]);
|
||||
|
||||
// Invite a new user
|
||||
const inviteUser = async (referredAddress: string): Promise<boolean> => {
|
||||
if (!api || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Wallet not connected',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate address format
|
||||
if (!referredAddress || referredAddress.length < 47) {
|
||||
toast({
|
||||
title: 'Invalid Address',
|
||||
description: 'Please enter a valid Pezkuwi address',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Sending Invitation',
|
||||
description: 'Please sign the transaction...',
|
||||
});
|
||||
|
||||
await initiateReferral(api, { address: account, meta: { source: 'pezkuwi' } } as Record<string, unknown>, referredAddress);
|
||||
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: 'Referral invitation sent successfully',
|
||||
});
|
||||
|
||||
// Refresh stats after successful invitation
|
||||
await fetchStats();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Error inviting user:', error);
|
||||
|
||||
let errorMessage = 'Failed to send referral invitation';
|
||||
if (error.message) {
|
||||
if (error.message.includes('SelfReferral')) {
|
||||
errorMessage = 'You cannot refer yourself';
|
||||
} else if (error.message.includes('AlreadyReferred')) {
|
||||
errorMessage = 'This user has already been referred';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const value: ReferralContextValue = {
|
||||
stats,
|
||||
myReferrals,
|
||||
loading,
|
||||
inviteUser,
|
||||
refreshStats: fetchStats,
|
||||
};
|
||||
|
||||
return <ReferralContext.Provider value={value}>{children}</ReferralContext.Provider>;
|
||||
}
|
||||
|
||||
export function useReferral() {
|
||||
const context = useContext(ReferralContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useReferral must be used within a ReferralProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
+17
-306
@@ -1,314 +1,25 @@
|
||||
// ========================================
|
||||
// WalletContext - Pezkuwi.js Wallet Integration
|
||||
// ========================================
|
||||
// This context wraps PezkuwiContext and provides wallet functionality
|
||||
// ⚠️ MIGRATION NOTE: This now uses Pezkuwi.js instead of MetaMask/Ethereum
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { usePezkuwi } from './PezkuwiContext';
|
||||
import { WALLET_ERRORS, formatBalance, ASSET_IDS } from '@pezkuwi/lib/wallet';
|
||||
import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
|
||||
import type { Signer } from '@pezkuwi/api/types';
|
||||
import { web3FromAddress } from '@pezkuwi/extension-dapp';
|
||||
import { isMobileApp, signTransactionNative, type TransactionPayload } from '@/lib/mobile-bridge';
|
||||
|
||||
interface TokenBalances {
|
||||
HEZ: string;
|
||||
PEZ: string;
|
||||
wHEZ: string;
|
||||
USDT: string; // User-facing key for wUSDT (backend uses wUSDT asset ID 2)
|
||||
}
|
||||
/**
|
||||
* Wallet Context for P2P Mobile
|
||||
*
|
||||
* In the mobile P2P app, wallet address is linked to user's profile.
|
||||
* No direct blockchain API connection needed - trades use internal ledger.
|
||||
*/
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface WalletContextType {
|
||||
address: string | null;
|
||||
isConnected: boolean;
|
||||
account: string | null; // Current selected account address
|
||||
accounts: InjectedAccountWithMeta[];
|
||||
balance: string; // Legacy: HEZ balance
|
||||
balances: TokenBalances; // All token balances
|
||||
error: string | null;
|
||||
signer: Signer | null; // Pezkuwi.js signer for transactions
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
switchAccount: (account: InjectedAccountWithMeta) => void;
|
||||
signTransaction: (tx: unknown) => Promise<string>;
|
||||
signMessage: (message: string) => Promise<string>;
|
||||
refreshBalances: () => Promise<void>; // Refresh all token balances
|
||||
}
|
||||
|
||||
const WalletContext = createContext<WalletContextType | undefined>(undefined);
|
||||
const WalletContext = createContext<WalletContextType | null>(null);
|
||||
|
||||
export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const pezkuwi = usePezkuwi();
|
||||
|
||||
if (import.meta.env.DEV) console.log('🎯 WalletProvider render:', {
|
||||
hasApi: !!pezkuwi.api,
|
||||
isApiReady: pezkuwi.isApiReady,
|
||||
selectedAccount: pezkuwi.selectedAccount?.address,
|
||||
accountsCount: pezkuwi.accounts.length
|
||||
});
|
||||
|
||||
const [balance, setBalance] = useState<string>('0');
|
||||
const [balances, setBalances] = useState<TokenBalances>({ HEZ: '0', PEZ: '0', wHEZ: '0', USDT: '0' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [signer, setSigner] = useState<Signer | null>(null);
|
||||
|
||||
// Fetch all token balances when account changes
|
||||
const updateBalance = useCallback(async (address: string) => {
|
||||
if (!pezkuwi.api || !pezkuwi.isApiReady) {
|
||||
if (import.meta.env.DEV) console.warn('API not ready, cannot fetch balance');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('💰 Fetching all token balances for:', address);
|
||||
|
||||
// Fetch HEZ (native token)
|
||||
const { data: nativeBalance } = await pezkuwi.api.query.system.account(address);
|
||||
const hezBalance = formatBalance(nativeBalance.free.toString());
|
||||
setBalance(hezBalance); // Legacy support
|
||||
|
||||
// Fetch PEZ (Asset ID: 1)
|
||||
let pezBalance = '0';
|
||||
try {
|
||||
const pezData = await pezkuwi.api.query.assets.account(ASSET_IDS.PEZ, address);
|
||||
if (import.meta.env.DEV) console.log('📊 Raw PEZ data:', pezData.toHuman());
|
||||
|
||||
if (pezData.isSome) {
|
||||
const assetData = pezData.unwrap();
|
||||
const pezAmount = assetData.balance.toString();
|
||||
pezBalance = formatBalance(pezAmount);
|
||||
if (import.meta.env.DEV) console.log('✅ PEZ balance found:', pezBalance);
|
||||
} else {
|
||||
if (import.meta.env.DEV) console.warn('⚠️ PEZ asset not found for this account');
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('❌ Failed to fetch PEZ balance:', err);
|
||||
}
|
||||
|
||||
// Fetch wHEZ (Asset ID: 0)
|
||||
let whezBalance = '0';
|
||||
try {
|
||||
const whezData = await pezkuwi.api.query.assets.account(ASSET_IDS.WHEZ, address);
|
||||
if (import.meta.env.DEV) console.log('📊 Raw wHEZ data:', whezData.toHuman());
|
||||
|
||||
if (whezData.isSome) {
|
||||
const assetData = whezData.unwrap();
|
||||
const whezAmount = assetData.balance.toString();
|
||||
whezBalance = formatBalance(whezAmount);
|
||||
if (import.meta.env.DEV) console.log('✅ wHEZ balance found:', whezBalance);
|
||||
} else {
|
||||
if (import.meta.env.DEV) console.warn('⚠️ wHEZ asset not found for this account');
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('❌ Failed to fetch wHEZ balance:', err);
|
||||
}
|
||||
|
||||
// Fetch wUSDT (Asset ID: 2) - IMPORTANT: wUSDT has 6 decimals, not 12!
|
||||
let wusdtBalance = '0';
|
||||
try {
|
||||
const wusdtData = await pezkuwi.api.query.assets.account(ASSET_IDS.WUSDT, address);
|
||||
if (import.meta.env.DEV) console.log('📊 Raw wUSDT data:', wusdtData.toHuman());
|
||||
|
||||
if (wusdtData.isSome) {
|
||||
const assetData = wusdtData.unwrap();
|
||||
const wusdtAmount = assetData.balance.toString();
|
||||
wusdtBalance = formatBalance(wusdtAmount, 6); // wUSDT uses 6 decimals!
|
||||
if (import.meta.env.DEV) console.log('✅ wUSDT balance found:', wusdtBalance);
|
||||
} else {
|
||||
if (import.meta.env.DEV) console.warn('⚠️ wUSDT asset not found for this account');
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('❌ Failed to fetch wUSDT balance:', err);
|
||||
}
|
||||
|
||||
setBalances({
|
||||
HEZ: hezBalance,
|
||||
PEZ: pezBalance,
|
||||
wHEZ: whezBalance,
|
||||
USDT: wusdtBalance,
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) console.log('✅ Balances updated:', { HEZ: hezBalance, PEZ: pezBalance, wHEZ: whezBalance, wUSDT: wusdtBalance });
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to fetch balances:', err);
|
||||
setError('Failed to fetch balances');
|
||||
}
|
||||
}, [pezkuwi.api, pezkuwi.isApiReady]);
|
||||
|
||||
// Connect wallet (Pezkuwi.js extension)
|
||||
const connectWallet = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await pezkuwi.connectWallet();
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Wallet connection failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : WALLET_ERRORS.CONNECTION_FAILED;
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [pezkuwi]);
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnect = useCallback(() => {
|
||||
pezkuwi.disconnectWallet();
|
||||
setBalance('0');
|
||||
setError(null);
|
||||
}, [pezkuwi]);
|
||||
|
||||
// Switch account
|
||||
const switchAccount = useCallback((account: InjectedAccountWithMeta) => {
|
||||
pezkuwi.setSelectedAccount(account);
|
||||
}, [pezkuwi]);
|
||||
|
||||
// Sign and submit transaction
|
||||
const signTransaction = useCallback(async (tx: unknown): Promise<string> => {
|
||||
if (!pezkuwi.api || !pezkuwi.selectedAccount) {
|
||||
throw new Error(WALLET_ERRORS.API_NOT_READY);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if running in mobile app - use native bridge for signing
|
||||
if (isMobileApp()) {
|
||||
if (import.meta.env.DEV) console.log('[Mobile] Using native bridge for transaction signing');
|
||||
|
||||
// Extract transaction details from the tx object
|
||||
const txAny = tx as {
|
||||
method: {
|
||||
section: string;
|
||||
method: string;
|
||||
args: unknown[];
|
||||
toHuman?: () => { args?: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
|
||||
// Get section, method and args from the transaction
|
||||
const section = txAny.method.section;
|
||||
const method = txAny.method.method;
|
||||
|
||||
// Extract args - convert to array format
|
||||
const argsHuman = txAny.method.toHuman?.()?.args || {};
|
||||
const args = Object.values(argsHuman);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Transaction details:', { section, method, args });
|
||||
}
|
||||
|
||||
const payload: TransactionPayload = { section, method, args };
|
||||
|
||||
// Sign and send via native bridge
|
||||
const blockHash = await signTransactionNative(payload);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Transaction submitted, block hash:', blockHash);
|
||||
}
|
||||
|
||||
return blockHash;
|
||||
}
|
||||
|
||||
// Desktop: Use browser extension for signing
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
|
||||
// Sign and send transaction
|
||||
const hash = await (tx as { signAndSend: (address: string, options: { signer: unknown }) => Promise<{ toHex: () => string }> }).signAndSend(
|
||||
pezkuwi.selectedAccount.address,
|
||||
{ signer: injector.signer }
|
||||
);
|
||||
|
||||
return hash.toHex();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Transaction failed:', error);
|
||||
throw new Error(error instanceof Error ? error.message : WALLET_ERRORS.TRANSACTION_FAILED);
|
||||
}
|
||||
}, [pezkuwi.api, pezkuwi.selectedAccount]);
|
||||
|
||||
// Sign message
|
||||
const signMessage = useCallback(async (message: string): Promise<string> => {
|
||||
if (!pezkuwi.selectedAccount) {
|
||||
throw new Error('No account selected');
|
||||
}
|
||||
|
||||
try {
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
|
||||
if (!injector.signer.signRaw) {
|
||||
throw new Error('Wallet does not support message signing');
|
||||
}
|
||||
|
||||
const { signature } = await injector.signer.signRaw({
|
||||
address: pezkuwi.selectedAccount.address,
|
||||
data: message,
|
||||
type: 'bytes'
|
||||
});
|
||||
|
||||
return signature;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Message signing failed:', error);
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to sign message');
|
||||
}
|
||||
}, [pezkuwi.selectedAccount]);
|
||||
|
||||
// Get signer from extension when account changes
|
||||
useEffect(() => {
|
||||
const getSigner = async () => {
|
||||
if (pezkuwi.selectedAccount) {
|
||||
try {
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
setSigner(injector.signer);
|
||||
if (import.meta.env.DEV) console.log('✅ Signer obtained for', pezkuwi.selectedAccount.address);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Failed to get signer:', error);
|
||||
setSigner(null);
|
||||
}
|
||||
} else {
|
||||
setSigner(null);
|
||||
}
|
||||
};
|
||||
|
||||
getSigner();
|
||||
}, [pezkuwi.selectedAccount]);
|
||||
|
||||
// Update balance when selected account changes
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) console.log('🔄 WalletContext useEffect triggered!', {
|
||||
hasAccount: !!pezkuwi.selectedAccount,
|
||||
isApiReady: pezkuwi.isApiReady,
|
||||
address: pezkuwi.selectedAccount?.address
|
||||
});
|
||||
|
||||
if (pezkuwi.selectedAccount && pezkuwi.isApiReady) {
|
||||
updateBalance(pezkuwi.selectedAccount.address);
|
||||
}
|
||||
}, [pezkuwi.selectedAccount, pezkuwi.isApiReady, updateBalance]);
|
||||
|
||||
// Sync error state with PezkuwiContext
|
||||
useEffect(() => {
|
||||
if (pezkuwi.error) {
|
||||
setError(pezkuwi.error);
|
||||
}
|
||||
}, [pezkuwi.error]);
|
||||
|
||||
// Refresh balances for current account
|
||||
const refreshBalances = useCallback(async () => {
|
||||
if (pezkuwi.selectedAccount) {
|
||||
await updateBalance(pezkuwi.selectedAccount.address);
|
||||
}
|
||||
}, [pezkuwi.selectedAccount, updateBalance]);
|
||||
export function WalletProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const value: WalletContextType = {
|
||||
isConnected: pezkuwi.accounts.length > 0,
|
||||
account: pezkuwi.selectedAccount?.address || null,
|
||||
accounts: pezkuwi.accounts,
|
||||
balance,
|
||||
balances,
|
||||
error: error || pezkuwi.error,
|
||||
signer,
|
||||
connectWallet,
|
||||
disconnect,
|
||||
switchAccount,
|
||||
signTransaction,
|
||||
signMessage,
|
||||
refreshBalances,
|
||||
address: user?.wallet_address || null,
|
||||
isConnected: !!user?.wallet_address,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -316,12 +27,12 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
{children}
|
||||
</WalletContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const useWallet = () => {
|
||||
export function useWallet() {
|
||||
const context = useContext(WalletContext);
|
||||
if (!context) {
|
||||
throw new Error('useWallet must be used within WalletProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Helper to get environment variables that works in both web (Vite) and React Native (Expo)
|
||||
const getEnv = (key: string): string | undefined => {
|
||||
// Check for Vite environment (web)
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||
return (import.meta.env as any)[key];
|
||||
}
|
||||
// Check for Expo environment (React Native)
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
const expoKey = key.replace('VITE_', 'EXPO_PUBLIC_');
|
||||
return process.env[expoKey] || process.env[key];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const PINATA_JWT = getEnv('VITE_PINATA_JWT');
|
||||
const PINATA_API = 'https://api.pinata.cloud/pinning/pinFileToIPFS';
|
||||
|
||||
export async function uploadToIPFS(file: File): Promise<string> {
|
||||
if (!PINATA_JWT || PINATA_JWT === 'your_pinata_jwt_here') {
|
||||
throw new Error('Pinata JWT not configured. Set VITE_PINATA_JWT in .env');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(PINATA_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${PINATA_JWT}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.IpfsHash; // Returns: Qm...
|
||||
} catch (error) {
|
||||
console.error('IPFS upload error:', error);
|
||||
toast.error('Failed to upload to IPFS');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIPFSUrl(hash: string): string {
|
||||
return `https://gateway.pinata.cloud/ipfs/${hash}`;
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* P2P Fiat Trading System - Mobile Version (OKX-Style Internal Ledger)
|
||||
*
|
||||
* @module p2p-fiat
|
||||
* @description P2P fiat-to-crypto trading with internal ledger escrow
|
||||
*
|
||||
* Security Model:
|
||||
* - Authentication via Telegram initData (cryptographically signed)
|
||||
* - Blockchain transactions ONLY occur at deposit/withdraw (backend)
|
||||
* - P2P trades use internal database balance transfers
|
||||
* - No client-side blockchain transactions
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
// =====================================================
|
||||
// TYPES
|
||||
// =====================================================
|
||||
|
||||
export type FiatCurrency =
|
||||
| 'TRY' | 'IQD' | 'IRR' | 'EUR' | 'USD' | 'GBP'
|
||||
| 'SEK' | 'CHF' | 'NOK' | 'DKK' | 'AUD' | 'CAD';
|
||||
|
||||
export type CryptoToken = 'HEZ' | 'PEZ';
|
||||
export type OfferStatus = 'open' | 'paused' | 'locked' | 'completed' | 'cancelled';
|
||||
export type TradeStatus = 'pending' | 'payment_sent' | 'completed' | 'cancelled' | 'disputed' | 'refunded';
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
currency: FiatCurrency;
|
||||
country: string;
|
||||
method_name: string;
|
||||
method_type: 'bank' | 'mobile_payment' | 'cash' | 'crypto_exchange';
|
||||
logo_url?: string;
|
||||
fields: Record<string, string>;
|
||||
validation_rules: Record<string, ValidationRule>;
|
||||
min_trade_amount: number;
|
||||
max_trade_amount?: number;
|
||||
processing_time_minutes: number;
|
||||
}
|
||||
|
||||
export interface ValidationRule {
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface P2PFiatOffer {
|
||||
id: string;
|
||||
seller_id: string;
|
||||
seller_wallet: string;
|
||||
token: CryptoToken;
|
||||
amount_crypto: number;
|
||||
fiat_currency: FiatCurrency;
|
||||
fiat_amount: number;
|
||||
price_per_unit: number;
|
||||
payment_method_id: string;
|
||||
payment_details_encrypted: string;
|
||||
min_order_amount?: number;
|
||||
max_order_amount?: number;
|
||||
time_limit_minutes: number;
|
||||
auto_reply_message?: string;
|
||||
min_buyer_completed_trades: number;
|
||||
min_buyer_reputation: number;
|
||||
status: OfferStatus;
|
||||
remaining_amount: number;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface P2PFiatTrade {
|
||||
id: string;
|
||||
offer_id: string;
|
||||
seller_id: string;
|
||||
buyer_id: string;
|
||||
buyer_wallet: string;
|
||||
crypto_amount: number;
|
||||
fiat_amount: number;
|
||||
price_per_unit: number;
|
||||
escrow_locked_amount: number;
|
||||
buyer_marked_paid_at?: string;
|
||||
buyer_payment_proof_url?: string;
|
||||
seller_confirmed_at?: string;
|
||||
status: TradeStatus;
|
||||
payment_deadline: string;
|
||||
confirmation_deadline?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface P2PReputation {
|
||||
user_id: string;
|
||||
total_trades: number;
|
||||
completed_trades: number;
|
||||
cancelled_trades: number;
|
||||
disputed_trades: number;
|
||||
reputation_score: number;
|
||||
trust_level: 'new' | 'basic' | 'intermediate' | 'advanced' | 'verified';
|
||||
verified_merchant: boolean;
|
||||
avg_payment_time_minutes?: number;
|
||||
}
|
||||
|
||||
export interface InternalBalance {
|
||||
token: CryptoToken;
|
||||
available_balance: number;
|
||||
locked_balance: number;
|
||||
total_balance: number;
|
||||
total_deposited: number;
|
||||
total_withdrawn: number;
|
||||
}
|
||||
|
||||
export interface DepositWithdrawRequest {
|
||||
id: string;
|
||||
user_id: string;
|
||||
request_type: 'deposit' | 'withdraw';
|
||||
token: CryptoToken;
|
||||
amount: number;
|
||||
wallet_address: string;
|
||||
blockchain_tx_hash?: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
processed_at?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// VALIDATION
|
||||
// =====================================================
|
||||
|
||||
export interface ValidationRule {
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment details against method rules
|
||||
*/
|
||||
export function validatePaymentDetails(
|
||||
paymentDetails: Record<string, string>,
|
||||
validationRules: Record<string, ValidationRule>
|
||||
): { valid: boolean; errors: Record<string, string> } {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const [field, rules] of Object.entries(validationRules)) {
|
||||
const value = paymentDetails[field] || '';
|
||||
|
||||
if (rules.required && !value) {
|
||||
errors[field] = 'This field is required';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rules.pattern && value) {
|
||||
const regex = new RegExp(rules.pattern);
|
||||
if (!regex.test(value)) {
|
||||
errors[field] = 'Invalid format';
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.minLength && value.length < rules.minLength) {
|
||||
errors[field] = `Minimum ${rules.minLength} characters`;
|
||||
}
|
||||
|
||||
if (rules.maxLength && value.length > rules.maxLength) {
|
||||
errors[field] = `Maximum ${rules.maxLength} characters`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: Object.keys(errors).length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// PAYMENT METHODS
|
||||
// =====================================================
|
||||
|
||||
export async function getPaymentMethods(currency: FiatCurrency): Promise<PaymentMethod[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('payment_methods')
|
||||
.select('*')
|
||||
.eq('currency', currency)
|
||||
.eq('is_active', true)
|
||||
.order('display_order');
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Get payment methods error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OFFERS
|
||||
// =====================================================
|
||||
|
||||
export async function getActiveOffers(
|
||||
currency?: FiatCurrency,
|
||||
token?: CryptoToken
|
||||
): Promise<P2PFiatOffer[]> {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('*')
|
||||
.eq('status', 'open')
|
||||
.gt('remaining_amount', 0)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.order('price_per_unit');
|
||||
|
||||
if (currency) query = query.eq('fiat_currency', currency);
|
||||
if (token) query = query.eq('token', token);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Get active offers error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptFiatOffer(params: {
|
||||
offerId: string;
|
||||
buyerWallet: string;
|
||||
amount?: number;
|
||||
}): Promise<string> {
|
||||
const { offerId, buyerWallet, amount } = params;
|
||||
|
||||
try {
|
||||
const { data: user } = await supabase.auth.getUser();
|
||||
if (!user.user) throw new Error('Not authenticated');
|
||||
|
||||
const { data: offer } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('remaining_amount, min_buyer_completed_trades, min_buyer_reputation')
|
||||
.eq('id', offerId)
|
||||
.single();
|
||||
|
||||
if (!offer) throw new Error('Offer not found');
|
||||
|
||||
const tradeAmount = amount || offer.remaining_amount;
|
||||
|
||||
// Check reputation requirements
|
||||
if (offer.min_buyer_completed_trades > 0 || offer.min_buyer_reputation > 0) {
|
||||
const { data: reputation } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('completed_trades, reputation_score')
|
||||
.eq('user_id', user.user.id)
|
||||
.single();
|
||||
|
||||
if (!reputation) {
|
||||
throw new Error('Seller requires experienced buyers');
|
||||
}
|
||||
if (reputation.completed_trades < offer.min_buyer_completed_trades) {
|
||||
throw new Error(`Minimum ${offer.min_buyer_completed_trades} completed trades required`);
|
||||
}
|
||||
if (reputation.reputation_score < offer.min_buyer_reputation) {
|
||||
throw new Error(`Minimum reputation score ${offer.min_buyer_reputation} required`);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic accept
|
||||
const { data: result, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
|
||||
p_offer_id: offerId,
|
||||
p_buyer_id: user.user.id,
|
||||
p_buyer_wallet: buyerWallet,
|
||||
p_amount: tradeAmount
|
||||
});
|
||||
|
||||
if (rpcError) throw rpcError;
|
||||
|
||||
const response = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to accept offer');
|
||||
}
|
||||
|
||||
toast.success('Trade started! Send payment within time limit.');
|
||||
return response.trade_id;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to accept offer';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TRADES
|
||||
// =====================================================
|
||||
|
||||
export async function getUserTrades(userId: string): Promise<P2PFiatTrade[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*')
|
||||
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Get user trades error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTradeById(tradeId: string): Promise<P2PFiatTrade | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*')
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Get trade by ID error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markPaymentSent(
|
||||
tradeId: string,
|
||||
paymentProofUrl?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const confirmationDeadline = new Date(Date.now() + 60 * 60 * 1000); // 60 min
|
||||
|
||||
const { error } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
buyer_marked_paid_at: new Date().toISOString(),
|
||||
buyer_payment_proof_url: paymentProofUrl,
|
||||
status: 'payment_sent',
|
||||
confirmation_deadline: confirmationDeadline.toISOString()
|
||||
})
|
||||
.eq('id', tradeId);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Payment marked as sent. Waiting for seller confirmation...');
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to mark payment';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmPaymentReceived(tradeId: string): Promise<void> {
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const sellerId = userData.user?.id;
|
||||
if (!sellerId) throw new Error('Not authenticated');
|
||||
|
||||
const { data: trade } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*, p2p_fiat_offers(token)')
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (!trade) throw new Error('Trade not found');
|
||||
if (trade.seller_id !== sellerId) throw new Error('Only seller can confirm');
|
||||
if (trade.status !== 'payment_sent') throw new Error('Payment not marked as sent');
|
||||
|
||||
toast.info('Releasing crypto to buyer...');
|
||||
|
||||
// Release escrow internally
|
||||
const { data: result, error: releaseError } = await supabase.rpc('release_escrow_internal', {
|
||||
p_from_user_id: trade.seller_id,
|
||||
p_to_user_id: trade.buyer_id,
|
||||
p_token: trade.p2p_fiat_offers.token,
|
||||
p_amount: trade.crypto_amount,
|
||||
p_reference_type: 'trade',
|
||||
p_reference_id: tradeId
|
||||
});
|
||||
|
||||
if (releaseError) throw releaseError;
|
||||
|
||||
const response = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
if (!response.success) throw new Error(response.error || 'Failed to release');
|
||||
|
||||
// Update trade status
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
seller_confirmed_at: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', tradeId);
|
||||
|
||||
toast.success('Payment confirmed! Crypto released to buyer.');
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to confirm';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTrade(tradeId: string, reason?: string): Promise<void> {
|
||||
try {
|
||||
const { data: user } = await supabase.auth.getUser();
|
||||
if (!user.user) throw new Error('Not authenticated');
|
||||
|
||||
const { data: trade } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*')
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (!trade) throw new Error('Trade not found');
|
||||
if (trade.status !== 'pending') throw new Error('Cannot cancel at this stage');
|
||||
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
cancelled_by: user.user.id,
|
||||
cancel_reason: reason
|
||||
})
|
||||
.eq('id', tradeId);
|
||||
|
||||
// Restore offer amount
|
||||
const { data: offer } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('remaining_amount')
|
||||
.eq('id', trade.offer_id)
|
||||
.single();
|
||||
|
||||
if (offer) {
|
||||
await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.update({
|
||||
remaining_amount: offer.remaining_amount + trade.crypto_amount,
|
||||
status: 'open'
|
||||
})
|
||||
.eq('id', trade.offer_id);
|
||||
}
|
||||
|
||||
toast.success('Trade cancelled');
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// REPUTATION
|
||||
// =====================================================
|
||||
|
||||
export async function getUserReputation(userId: string): Promise<P2PReputation | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Get reputation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// INTERNAL BALANCE (OKX-Style)
|
||||
// =====================================================
|
||||
|
||||
export async function getInternalBalances(): Promise<InternalBalance[]> {
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData.user?.id;
|
||||
if (!userId) return [];
|
||||
|
||||
const { data, error } = await supabase.rpc('get_user_internal_balance', {
|
||||
p_user_id: userId
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return typeof data === 'string' ? JSON.parse(data) : (data || []);
|
||||
} catch (error) {
|
||||
console.error('Get balances error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInternalBalance(token: CryptoToken): Promise<InternalBalance | null> {
|
||||
const balances = await getInternalBalances();
|
||||
return balances.find(b => b.token === token) || null;
|
||||
}
|
||||
|
||||
export async function requestWithdraw(
|
||||
token: CryptoToken,
|
||||
amount: number,
|
||||
walletAddress: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData.user?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
|
||||
if (amount <= 0) throw new Error('Amount must be greater than 0');
|
||||
if (!walletAddress || walletAddress.length < 40) throw new Error('Invalid wallet address');
|
||||
|
||||
toast.info('Processing withdrawal request...');
|
||||
|
||||
const { data, error } = await supabase.rpc('request_withdraw', {
|
||||
p_user_id: userId,
|
||||
p_token: token,
|
||||
p_amount: amount,
|
||||
p_wallet_address: walletAddress
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
if (!result.success) throw new Error(result.error || 'Request failed');
|
||||
|
||||
toast.success(`Withdrawal request submitted! ${amount} ${token} will be sent.`);
|
||||
return result.request_id;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Request failed';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawHistory(): Promise<DepositWithdrawRequest[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_deposit_withdraw_requests')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Get history error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformWalletAddress(): Promise<string> {
|
||||
const DEFAULT_ADDRESS = '5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3';
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_config')
|
||||
.select('value')
|
||||
.eq('key', 'platform_escrow_wallet')
|
||||
.single();
|
||||
|
||||
if (error) return DEFAULT_ADDRESS;
|
||||
return data?.value || DEFAULT_ADDRESS;
|
||||
} catch {
|
||||
return DEFAULT_ADDRESS;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,4 +10,4 @@ if (!supabaseUrl || !supabaseKey) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
export { supabase };
|
||||
export { supabase };
|
||||
|
||||
+8
-3
@@ -2,13 +2,18 @@ import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
* Web-specific className utility (uses Tailwind merge)
|
||||
* Utility function to merge class names with Tailwind
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export formatNumber from shared utils
|
||||
* Format a number with locale-specific formatting
|
||||
*/
|
||||
export { formatNumber } from '@pezkuwi/utils/format';
|
||||
export function formatNumber(value: number, decimals: number = 2): string {
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,9 +5,15 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
+8
-1
@@ -1,7 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/p2p/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user