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:
2026-01-31 09:38:20 +03:00
parent bc7ab9b2a4
commit 8add193aa1
25 changed files with 6383 additions and 2162 deletions
+2
View File
@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
# Editor directories and files
.vscode/*
+4614
View File
File diff suppressed because it is too large Load Diff
+18 -5
View File
@@ -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",
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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
View File
@@ -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>
);
+2 -9
View File
@@ -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');
+1 -1
View File
@@ -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;
+95
View File
@@ -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>
);
}
+15 -55
View File
@@ -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
View File
@@ -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)}
+241
View File
@@ -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>
);
}
+49 -85
View File
@@ -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>
+126 -355
View File
@@ -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
View File
@@ -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&apos;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;
}
-98
View File
@@ -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;
}
-330
View File
@@ -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;
};
-177
View File
@@ -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
View File
@@ -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;
};
}
+53
View File
@@ -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}`;
}
+568
View File
@@ -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
View File
@@ -10,4 +10,4 @@ if (!supabaseUrl || !supabaseKey) {
const supabase = createClient(supabaseUrl, supabaseKey);
export { supabase };
export { supabase };
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
},
},
})