diff --git a/package.json b/package.json index 4102aeb..50a09a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.225", + "version": "1.0.227", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/src/components/p2p/BalanceCard.tsx b/src/components/p2p/BalanceCard.tsx index 633e059..a9418e7 100644 --- a/src/components/p2p/BalanceCard.tsx +++ b/src/components/p2p/BalanceCard.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { Wallet, Lock, RefreshCw } from 'lucide-react'; +import { Wallet, Lock, RefreshCw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/contexts/AuthContext'; import { useTranslation } from '@/i18n'; @@ -8,9 +8,17 @@ import { getInternalBalance, type InternalBalance } from '@/lib/p2p-api'; interface BalanceCardProps { onRefresh?: () => void; + onDeposit?: () => void; + onWithdraw?: () => void; + onBalancesLoaded?: (balances: InternalBalance[]) => void; } -export function BalanceCard({ onRefresh }: BalanceCardProps) { +export function BalanceCard({ + onRefresh, + onDeposit, + onWithdraw, + onBalancesLoaded, +}: BalanceCardProps) { const { sessionToken } = useAuth(); const { t, isRTL } = useTranslation(); const { hapticImpact } = useTelegram(); @@ -24,13 +32,14 @@ export function BalanceCard({ onRefresh }: BalanceCardProps) { try { const data = await getInternalBalance(sessionToken); setBalances(data); + onBalancesLoaded?.(data); } catch (err) { console.error('Failed to fetch balances:', err); } finally { setLoading(false); setRefreshing(false); } - }, [sessionToken]); + }, [sessionToken, onBalancesLoaded]); useEffect(() => { fetchBalances(); @@ -103,6 +112,30 @@ export function BalanceCard({ onRefresh }: BalanceCardProps) { ))} )} + + {/* Deposit / Withdraw buttons */} +
+ + +
); } diff --git a/src/components/p2p/DepositWithdrawModal.tsx b/src/components/p2p/DepositWithdrawModal.tsx new file mode 100644 index 0000000..cddeb76 --- /dev/null +++ b/src/components/p2p/DepositWithdrawModal.tsx @@ -0,0 +1,543 @@ +import { useState, useCallback } from 'react'; +import { + X, + Copy, + Check, + ArrowDownToLine, + ArrowUpFromLine, + Loader2, + AlertCircle, + CheckCircle2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/contexts/AuthContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { useTranslation } from '@/i18n'; +import { useTelegram } from '@/hooks/useTelegram'; +import { verifyDeposit, requestWithdraw } from '@/lib/p2p-api'; + +const PLATFORM_WALLET = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS'; + +const WITHDRAW_FEE: Record = { + HEZ: 0.1, + PEZ: 1, +}; + +const MIN_WITHDRAW: Record = { + HEZ: 1, + PEZ: 10, +}; + +interface DepositWithdrawModalProps { + isOpen: boolean; + onClose: () => void; + initialTab?: 'deposit' | 'withdraw'; + availableBalances?: Record; + onSuccess?: () => void; +} + +type DepositStep = 'form' | 'verifying' | 'success' | 'error'; + +export function DepositWithdrawModal({ + isOpen, + onClose, + initialTab = 'deposit', + availableBalances = {}, + onSuccess, +}: DepositWithdrawModalProps) { + const { sessionToken } = useAuth(); + const { address } = useWallet(); + const { t, isRTL } = useTranslation(); + const { hapticImpact, hapticNotification } = useTelegram(); + + const [activeTab, setActiveTab] = useState<'deposit' | 'withdraw'>(initialTab); + + // Deposit state + const [depositToken, setDepositToken] = useState<'HEZ' | 'PEZ'>('HEZ'); + const [depositAmount, setDepositAmount] = useState(''); + const [txHash, setTxHash] = useState(''); + const [blockNumber, setBlockNumber] = useState(''); + const [depositStep, setDepositStep] = useState('form'); + const [depositError, setDepositError] = useState(''); + const [depositResult, setDepositResult] = useState<{ amount: number; token: string } | null>( + null + ); + const [copied, setCopied] = useState(false); + + // Withdraw state + const [withdrawToken, setWithdrawToken] = useState<'HEZ' | 'PEZ'>('HEZ'); + const [withdrawAmount, setWithdrawAmount] = useState(''); + const [withdrawAddress, setWithdrawAddress] = useState(address || ''); + const [withdrawLoading, setWithdrawLoading] = useState(false); + const [withdrawError, setWithdrawError] = useState(''); + const [withdrawSuccess, setWithdrawSuccess] = useState(false); + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(PLATFORM_WALLET); + setCopied(true); + hapticNotification('success'); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for environments where clipboard API is not available + const textArea = document.createElement('textarea'); + textArea.value = PLATFORM_WALLET; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + hapticNotification('success'); + setTimeout(() => setCopied(false), 2000); + } + }, [hapticNotification]); + + const handleVerifyDeposit = async () => { + if (!sessionToken || !txHash || !depositAmount) return; + + const amount = parseFloat(depositAmount); + if (isNaN(amount) || amount <= 0) { + setDepositError(t('p2p.depositInvalidAmount')); + return; + } + + hapticImpact('medium'); + setDepositStep('verifying'); + setDepositError(''); + + try { + const result = await verifyDeposit( + sessionToken, + txHash.trim(), + depositToken, + amount, + blockNumber ? parseInt(blockNumber) : undefined + ); + + setDepositResult({ amount: result.amount, token: result.token }); + setDepositStep('success'); + hapticNotification('success'); + onSuccess?.(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setDepositError(message); + setDepositStep('error'); + hapticNotification('error'); + } + }; + + const handleWithdraw = async () => { + if (!sessionToken || !withdrawAmount || !withdrawAddress) return; + + const amount = parseFloat(withdrawAmount); + if (isNaN(amount) || amount <= 0) { + setWithdrawError(t('p2p.depositInvalidAmount')); + return; + } + + if (amount < MIN_WITHDRAW[withdrawToken]) { + setWithdrawError(`${t('p2p.minWithdraw')}: ${MIN_WITHDRAW[withdrawToken]} ${withdrawToken}`); + return; + } + + const available = availableBalances[withdrawToken] || 0; + if (amount > available) { + setWithdrawError(t('p2p.insufficientBalance')); + return; + } + + hapticImpact('medium'); + setWithdrawLoading(true); + setWithdrawError(''); + + try { + await requestWithdraw(sessionToken, withdrawToken, amount, withdrawAddress.trim()); + setWithdrawSuccess(true); + hapticNotification('success'); + onSuccess?.(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setWithdrawError(message); + hapticNotification('error'); + } finally { + setWithdrawLoading(false); + } + }; + + const resetDeposit = () => { + setDepositStep('form'); + setDepositError(''); + setDepositResult(null); + setTxHash(''); + setBlockNumber(''); + setDepositAmount(''); + }; + + const resetWithdraw = () => { + setWithdrawError(''); + setWithdrawSuccess(false); + setWithdrawAmount(''); + }; + + const handleClose = () => { + resetDeposit(); + resetWithdraw(); + onClose(); + }; + + if (!isOpen) return null; + + const fee = WITHDRAW_FEE[withdrawToken] || 0; + const withdrawNum = parseFloat(withdrawAmount) || 0; + const netAmount = Math.max(0, withdrawNum - fee); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ + +
+ +
+ +
+ {/* ── Deposit Tab ── */} + {activeTab === 'deposit' && ( + <> + {depositStep === 'form' && ( + <> + {/* Instructions */} +
+

{t('p2p.depositInstructions')}

+
+ + {/* Platform wallet address */} +
+ +
+ + {PLATFORM_WALLET} + + +
+
+ + {/* Token select */} +
+ +
+ {(['HEZ', 'PEZ'] as const).map((tok) => ( + + ))} +
+
+ + {/* Amount */} +
+ + setDepositAmount(e.target.value)} + placeholder="0.00" + className="w-full bg-muted rounded-lg px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-cyan-500" + /> +
+ + {/* TX Hash */} +
+ + setTxHash(e.target.value)} + placeholder={t('p2p.txHashPlaceholder')} + className="w-full bg-muted rounded-lg px-3 py-2.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-cyan-500 font-mono" + /> +
+ + {/* Block Number (optional) */} +
+ + setBlockNumber(e.target.value)} + placeholder="e.g. 1234567" + className="w-full bg-muted rounded-lg px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-cyan-500" + /> +
+ + {/* Verify button */} + + + )} + + {depositStep === 'verifying' && ( +
+ +

{t('p2p.verifying')}

+

+ {t('p2p.verifyingDesc')} +

+
+ )} + + {depositStep === 'success' && depositResult && ( +
+ +

{t('p2p.depositSuccess')}

+

+ +{depositResult.amount} {depositResult.token} +

+ +
+ )} + + {depositStep === 'error' && ( +
+ +

{t('p2p.depositFailed')}

+

{depositError}

+ +
+ )} + + )} + + {/* ── Withdraw Tab ── */} + {activeTab === 'withdraw' && ( + <> + {withdrawSuccess ? ( +
+ +

{t('p2p.withdrawSuccess')}

+

+ {t('p2p.withdrawProcessing')} +

+ +
+ ) : ( + <> + {/* Token select */} +
+ +
+ {(['HEZ', 'PEZ'] as const).map((tok) => ( + + ))} +
+
+ + {/* Amount */} +
+
+ + +
+ { + setWithdrawAmount(e.target.value); + setWithdrawError(''); + }} + placeholder="0.00" + className="w-full bg-muted rounded-lg px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-cyan-500" + /> +
+ + {/* Wallet address */} +
+ + setWithdrawAddress(e.target.value)} + placeholder="5..." + className="w-full bg-muted rounded-lg px-3 py-2.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-cyan-500 font-mono" + /> +
+ + {/* Fee info */} +
+
+ {t('p2p.networkFee')} + + {fee} {withdrawToken} + +
+
+ {t('p2p.netAmount')} + + {netAmount.toFixed(4)} {withdrawToken} + +
+
+ {t('p2p.minWithdraw')} + + {MIN_WITHDRAW[withdrawToken]} {withdrawToken} + +
+
+ + {/* Error */} + {withdrawError && ( +
+ +

{withdrawError}

+
+ )} + + {/* Withdraw button */} + + + )} + + )} +
+
+
+ ); +} diff --git a/src/i18n/translations/ar.ts b/src/i18n/translations/ar.ts index 89b9889..96eef43 100644 --- a/src/i18n/translations/ar.ts +++ b/src/i18n/translations/ar.ts @@ -395,6 +395,31 @@ const ar: Translations = { fake_payment_proof: 'إثبات دفع مزور', other: 'أخرى', }, + // Deposit / Withdraw + deposit: 'إيداع', + withdraw: 'سحب', + platformWallet: 'عنوان محفظة المنصة', + txHash: 'هاش المعاملة', + txHashPlaceholder: '0x...', + blockNumber: 'رقم الكتلة', + optional: 'اختياري', + verifyDeposit: 'تحقق من الإيداع', + verifying: 'جارٍ التحقق...', + verifyingDesc: 'يتم فحص المعاملة على السلسلة. قد يستغرق هذا حتى ٦٠ ثانية.', + depositSuccess: 'تم الإيداع بنجاح!', + depositFailed: 'فشل الإيداع', + depositInstructions: 'أرسل التوكنات إلى عنوان محفظة المنصة أدناه، ثم الصق هاش المعاملة للتحقق.', + depositInvalidAmount: 'يرجى إدخال مبلغ صالح', + selectToken: 'اختر التوكن', + withdrawAmount: 'مبلغ السحب', + networkFee: 'رسوم الشبكة', + netAmount: 'المبلغ المستلم', + withdrawSuccess: 'تم طلب السحب!', + withdrawProcessing: 'يتم معالجة سحبك. سيتم إرسال التوكنات إلى محفظتك قريباً.', + withdrawing: 'جارٍ المعالجة...', + minWithdraw: 'الحد الأدنى للسحب', + maxAvailable: 'الحد الأقصى', + walletAddress: 'عنوان المحفظة', noTrades: 'لا توجد صفقات بعد', }, diff --git a/src/i18n/translations/ckb.ts b/src/i18n/translations/ckb.ts index 352cf3a..19c1ffb 100644 --- a/src/i18n/translations/ckb.ts +++ b/src/i18n/translations/ckb.ts @@ -397,6 +397,31 @@ const ckb: Translations = { fake_payment_proof: 'بەڵگەی پارەدانی ساختە', other: 'هی تر', }, + // Deposit / Withdraw + deposit: 'پارە دابنێ', + withdraw: 'پارە دەربهێنە', + platformWallet: 'ناونیشانی جزدانی پلاتفۆرم', + txHash: 'هاشی مامەڵە', + txHashPlaceholder: '0x...', + blockNumber: 'ژمارەی بلۆک', + optional: 'ئارەزوومەندانە', + verifyDeposit: 'پشتڕاستکردنەوەی پارەدانان', + verifying: 'پشتڕاستدەکرێتەوە...', + verifyingDesc: 'مامەڵە لەسەر زنجیرە پشکنین دەکرێت. ئەمە دەکرێت تا ٦٠ چرکە بخایەنێت.', + depositSuccess: 'پارەدانان سەرکەوتوو بوو!', + depositFailed: 'پارەدانان سەرنەکەوت', + depositInstructions: 'تۆکن بنێرە بۆ ناونیشانی جزدانی پلاتفۆرمی خوارەوە، پاشان هاشی مامەڵە بلکێنە بۆ پشتڕاستکردنەوە.', + depositInvalidAmount: 'تکایە بڕێکی دروست بنووسە', + selectToken: 'تۆکن هەڵبژێرە', + withdrawAmount: 'بڕی دەرهێنان', + networkFee: 'کرێی تۆڕ', + netAmount: 'وەردەگریت', + withdrawSuccess: 'داواکاری دەرهێنان دروستکرا!', + withdrawProcessing: 'دەرهێنانەکەت لە پرۆسەدایە. تۆکنەکان بەم زووانە دەنێردرێن بۆ جزدانەکەت.', + withdrawing: 'لە پرۆسەدایە...', + minWithdraw: 'کەمترین دەرهێنان', + maxAvailable: 'زۆرترین', + walletAddress: 'ناونیشانی جزدان', noTrades: 'هێشتا بازرگانی نییە', }, diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 582e08c..5ddce1b 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -396,6 +396,31 @@ const en: Translations = { fake_payment_proof: 'Fake payment proof', other: 'Other', }, + // Deposit / Withdraw + deposit: 'Deposit', + withdraw: 'Withdraw', + platformWallet: 'Platform Wallet Address', + txHash: 'Transaction Hash', + txHashPlaceholder: '0x...', + blockNumber: 'Block Number', + optional: 'optional', + verifyDeposit: 'Verify Deposit', + verifying: 'Verifying...', + verifyingDesc: 'Checking on-chain transaction. This may take up to 60 seconds.', + depositSuccess: 'Deposit Successful!', + depositFailed: 'Deposit Failed', + depositInstructions: 'Send tokens to the platform wallet address below, then paste the transaction hash to verify.', + depositInvalidAmount: 'Please enter a valid amount', + selectToken: 'Select Token', + withdrawAmount: 'Withdrawal Amount', + networkFee: 'Network Fee', + netAmount: 'You Receive', + withdrawSuccess: 'Withdrawal Requested!', + withdrawProcessing: 'Your withdrawal is being processed. Tokens will be sent to your wallet shortly.', + withdrawing: 'Processing...', + minWithdraw: 'Min Withdrawal', + maxAvailable: 'Max', + walletAddress: 'Wallet Address', noTrades: 'No trades yet', }, diff --git a/src/i18n/translations/fa.ts b/src/i18n/translations/fa.ts index 54ad522..cdd22cc 100644 --- a/src/i18n/translations/fa.ts +++ b/src/i18n/translations/fa.ts @@ -396,6 +396,31 @@ const fa: Translations = { fake_payment_proof: 'مدرک پرداخت جعلی', other: 'سایر', }, + // Deposit / Withdraw + deposit: 'واریز', + withdraw: 'برداشت', + platformWallet: 'آدرس کیف پول پلتفرم', + txHash: 'هش تراکنش', + txHashPlaceholder: '0x...', + blockNumber: 'شماره بلاک', + optional: 'اختیاری', + verifyDeposit: 'تایید واریز', + verifying: 'در حال تایید...', + verifyingDesc: 'تراکنش روی زنجیره بررسی می‌شود. این ممکن است تا ۶۰ ثانیه طول بکشد.', + depositSuccess: 'واریز موفق!', + depositFailed: 'واریز ناموفق', + depositInstructions: 'توکن‌ها را به آدرس کیف پول پلتفرم زیر ارسال کنید، سپس هش تراکنش را برای تایید جایگذاری کنید.', + depositInvalidAmount: 'لطفا مقدار معتبری وارد کنید', + selectToken: 'انتخاب توکن', + withdrawAmount: 'مقدار برداشت', + networkFee: 'کارمزد شبکه', + netAmount: 'دریافتی شما', + withdrawSuccess: 'درخواست برداشت ثبت شد!', + withdrawProcessing: 'برداشت شما در حال پردازش است. توکن‌ها به زودی به کیف پول شما ارسال می‌شوند.', + withdrawing: 'در حال پردازش...', + minWithdraw: 'حداقل برداشت', + maxAvailable: 'حداکثر', + walletAddress: 'آدرس کیف پول', noTrades: 'هنوز معامله‌ای نیست', }, diff --git a/src/i18n/translations/krd.ts b/src/i18n/translations/krd.ts index 87a202c..8c28534 100644 --- a/src/i18n/translations/krd.ts +++ b/src/i18n/translations/krd.ts @@ -408,6 +408,31 @@ const krd: Translations = { fake_payment_proof: 'Belgeya dravdanê ya sexte', other: 'Yên din', }, + // Deposit / Withdraw + deposit: 'Depo Bike', + withdraw: 'Derxe', + platformWallet: 'Navnîşana Cûzdanê ya Platformê', + txHash: 'Hash ya Danûstandinê', + txHashPlaceholder: '0x...', + blockNumber: 'Hejmara Blokê', + optional: 'ne mecbûrî', + verifyDeposit: 'Depoyê Verast Bike', + verifying: 'Tê verast kirin...', + verifyingDesc: 'Danûstandin li ser zincîrê tê kontrol kirin. Ev dikare heya 60 çirkeyan bigire.', + depositSuccess: 'Depo Serkeftî!', + depositFailed: 'Depo Serneket', + depositInstructions: 'Token bişîne navnîşana cûzdanê ya platformê ya jêrîn, paşê hash ya danûstandinê bişkoje ji bo verastkirin.', + depositInvalidAmount: 'Ji kerema xwe mîqdarek derbasdar binivîsin', + selectToken: 'Token Hilbijêrin', + withdrawAmount: 'Mîqdara Derxistinê', + networkFee: 'Heqê Torê', + netAmount: 'Hûn Digirin', + withdrawSuccess: 'Daxwaza Derxistinê Hat Afirandin!', + withdrawProcessing: 'Derxistina we tê pêvajokirin. Token dê di demeke kurt de bên şandin bo cûzdanê we.', + withdrawing: 'Tê pêvajokirin...', + minWithdraw: 'Kêmtirîn Derxistin', + maxAvailable: 'Herî zêde', + walletAddress: 'Navnîşana Cûzdanê', noTrades: 'Hê bazirganî tune', }, diff --git a/src/i18n/translations/tr.ts b/src/i18n/translations/tr.ts index 2970719..7dd2ee2 100644 --- a/src/i18n/translations/tr.ts +++ b/src/i18n/translations/tr.ts @@ -396,6 +396,31 @@ const tr: Translations = { fake_payment_proof: 'Sahte ödeme kanıtı', other: 'Diğer', }, + // Deposit / Withdraw + deposit: 'Yatır', + withdraw: 'Çek', + platformWallet: 'Platform Cüzdan Adresi', + txHash: 'İşlem Hash', + txHashPlaceholder: '0x...', + blockNumber: 'Blok Numarası', + optional: 'isteğe bağlı', + verifyDeposit: 'Yatırmayı Doğrula', + verifying: 'Doğrulanıyor...', + verifyingDesc: 'Zincir üzerinde işlem kontrol ediliyor. Bu 60 saniyeye kadar sürebilir.', + depositSuccess: 'Yatırma Başarılı!', + depositFailed: 'Yatırma Başarısız', + depositInstructions: 'Aşağıdaki platform cüzdan adresine token gönderin, ardından doğrulamak için işlem hash yapıştırın.', + depositInvalidAmount: 'Geçerli bir miktar girin', + selectToken: 'Token Seçin', + withdrawAmount: 'Çekim Miktarı', + networkFee: 'Ağ Ücreti', + netAmount: 'Alacağınız', + withdrawSuccess: 'Çekim Talebi Oluşturuldu!', + withdrawProcessing: 'Çekim işleminiz işleniyor. Tokenler kısa sürede cüzdanınıza gönderilecek.', + withdrawing: 'İşleniyor...', + minWithdraw: 'Min Çekim', + maxAvailable: 'Maks', + walletAddress: 'Cüzdan Adresi', noTrades: 'Henüz işlem yok', }, diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 6ccd908..7fd1ee0 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -406,6 +406,31 @@ export interface Translations { fake_payment_proof: string; other: string; }; + // Deposit / Withdraw + deposit: string; + withdraw: string; + platformWallet: string; + txHash: string; + txHashPlaceholder: string; + blockNumber: string; + optional: string; + verifyDeposit: string; + verifying: string; + verifyingDesc: string; + depositSuccess: string; + depositFailed: string; + depositInstructions: string; + depositInvalidAmount: string; + selectToken: string; + withdrawAmount: string; + networkFee: string; + netAmount: string; + withdrawSuccess: string; + withdrawProcessing: string; + withdrawing: string; + minWithdraw: string; + maxAvailable: string; + walletAddress: string; // No data noTrades: string; }; diff --git a/src/lib/p2p-api.ts b/src/lib/p2p-api.ts index 01de79e..b102321 100644 --- a/src/lib/p2p-api.ts +++ b/src/lib/p2p-api.ts @@ -134,6 +134,56 @@ export async function getInternalBalance(sessionToken: string): Promise { + return callEdgeFunction('verify-deposit-telegram', { + sessionToken, + txHash, + token, + expectedAmount, + blockNumber, + }); +} + +export async function requestWithdraw( + sessionToken: string, + token: 'HEZ' | 'PEZ', + amount: number, + walletAddress: string +): Promise { + return callEdgeFunction('request-withdraw-telegram', { + sessionToken, + token, + amount, + walletAddress, + }); +} + // ─── Payment Methods ───────────────────────────────────────── export async function getPaymentMethods( diff --git a/src/sections/P2P.tsx b/src/sections/P2P.tsx index 4de643c..092c7e1 100644 --- a/src/sections/P2P.tsx +++ b/src/sections/P2P.tsx @@ -1,15 +1,22 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Plus, History, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/contexts/AuthContext'; import { useTranslation } from '@/i18n'; import { useTelegram } from '@/hooks/useTelegram'; -import { getP2PTrades, getMyOffers, type P2POffer, type P2PTrade } from '@/lib/p2p-api'; +import { + getP2PTrades, + getMyOffers, + type P2POffer, + type P2PTrade, + type InternalBalance, +} from '@/lib/p2p-api'; import { BalanceCard } from '@/components/p2p/BalanceCard'; import { OfferList } from '@/components/p2p/OfferList'; import { TradeModal } from '@/components/p2p/TradeModal'; import { CreateOfferModal } from '@/components/p2p/CreateOfferModal'; import { TradeView } from '@/components/p2p/TradeView'; +import { DepositWithdrawModal } from '@/components/p2p/DepositWithdrawModal'; type Tab = 'buy' | 'sell' | 'myAds' | 'myTrades'; @@ -23,6 +30,35 @@ export function P2PSection() { const [showCreateOffer, setShowCreateOffer] = useState(false); const [activeTradeId, setActiveTradeId] = useState(null); + // Deposit/Withdraw modal state + const [showDepositWithdraw, setShowDepositWithdraw] = useState(false); + const [depositWithdrawTab, setDepositWithdrawTab] = useState<'deposit' | 'withdraw'>('deposit'); + const availableBalancesRef = useRef>({}); + const balanceCardRefreshRef = useRef<(() => void) | null>(null); + + const handleBalancesLoaded = useCallback((balances: InternalBalance[]) => { + const map: Record = {}; + for (const b of balances) { + map[b.token] = b.available_balance; + } + availableBalancesRef.current = map; + }, []); + + const handleOpenDeposit = () => { + setDepositWithdrawTab('deposit'); + setShowDepositWithdraw(true); + }; + + const handleOpenWithdraw = () => { + setDepositWithdrawTab('withdraw'); + setShowDepositWithdraw(true); + }; + + const handleDepositWithdrawSuccess = () => { + // Trigger balance card refresh + balanceCardRefreshRef.current?.(); + }; + // My ads / trades state const [myOffers, setMyOffers] = useState([]); const [myTrades, setMyTrades] = useState([]); @@ -145,7 +181,11 @@ export function P2PSection() {
{/* Balance Card */} - + {/* Tabs */}
@@ -284,6 +324,15 @@ export function P2PSection() { onClose={() => setShowCreateOffer(false)} onOfferCreated={handleOfferCreated} /> + + {/* Deposit / Withdraw Modal */} + setShowDepositWithdraw(false)} + initialTab={depositWithdrawTab} + availableBalances={availableBalancesRef.current} + onSuccess={handleDepositWithdrawSuccess} + />
); } diff --git a/src/version.json b/src/version.json index 9b8def4..d032c45 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.225", - "buildTime": "2026-02-26T14:51:09.120Z", - "buildNumber": 1772117469121 + "version": "1.0.227", + "buildTime": "2026-02-26T17:26:18.861Z", + "buildNumber": 1772126778862 } diff --git a/supabase/functions/request-withdraw-telegram/index.ts b/supabase/functions/request-withdraw-telegram/index.ts new file mode 100644 index 0000000..f7ab939 --- /dev/null +++ b/supabase/functions/request-withdraw-telegram/index.ts @@ -0,0 +1,282 @@ +// request-withdraw-telegram Edge Function +// For Telegram MiniApp users - creates a withdrawal request with session token auth +// The actual blockchain TX is handled by process-withdraw (cron/admin) + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { createHmac } from 'https://deno.land/std@0.177.0/node/crypto.ts'; + +// CORS - Production domain only +const ALLOWED_ORIGINS = [ + 'https://telegram.pezkuwichain.io', + 'https://telegram.pezkiwi.app', + 'https://t.me', +]; + +function getCorsHeaders(origin: string | null): Record { + const allowedOrigin = + origin && ALLOWED_ORIGINS.some((o) => origin.startsWith(o)) ? origin : ALLOWED_ORIGINS[0]; + + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + }; +} + +// Minimum withdrawal amounts +const MIN_WITHDRAW: Record = { + HEZ: 1, + PEZ: 10, +}; + +// Withdrawal fee (in tokens) +const WITHDRAW_FEE: Record = { + HEZ: 0.1, + PEZ: 1, +}; + +interface WithdrawRequest { + sessionToken: string; + token: 'HEZ' | 'PEZ'; + amount: number; + walletAddress: string; +} + +// Session token secret (derived from bot token) +function getSessionSecret(botToken: string): Uint8Array { + return createHmac('sha256', 'SessionTokenSecret').update(botToken).digest(); +} + +// Verify HMAC-signed session token +function verifySessionToken(token: string, botToken: string): number | null { + try { + const parts = token.split('.'); + if (parts.length !== 2) { + return verifyLegacyToken(token); + } + + const [payloadB64, signature] = parts; + + const secret = getSessionSecret(botToken); + const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex'); + + if (signature !== expectedSig) { + return null; + } + + const payload = JSON.parse(atob(payloadB64)); + + if (Date.now() > payload.exp) { + return null; + } + + return payload.tgId; + } catch { + return null; + } +} + +// Legacy token format (Base64 only) +function verifyLegacyToken(token: string): number | null { + try { + const decoded = atob(token); + const [telegramId, timestamp] = decoded.split(':'); + const ts = parseInt(timestamp); + if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) { + return null; + } + return parseInt(telegramId); + } catch { + return null; + } +} + +serve(async (req) => { + const origin = req.headers.get('origin'); + const corsHeaders = getCorsHeaders(origin); + + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + let body: WithdrawRequest; + try { + const text = await req.text(); + body = JSON.parse(text); + } catch { + return new Response( + JSON.stringify({ success: false, error: 'Invalid JSON in request body' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const { sessionToken, token, amount, walletAddress } = body; + + // Get bot token for session verification + const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN'); + if (!botToken) { + return new Response( + JSON.stringify({ success: false, error: 'Server configuration error' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Validate session token + if (!sessionToken) { + return new Response( + JSON.stringify({ success: false, error: 'Missing session token' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const telegramId = verifySessionToken(sessionToken, botToken); + if (!telegramId) { + return new Response( + JSON.stringify({ success: false, error: 'Invalid or expired session token' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create Supabase service client + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const serviceClient = createClient(supabaseUrl, supabaseServiceKey); + + // Get auth user for telegram user + const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`; + const { + data: { users: existingUsers }, + } = await serviceClient.auth.admin.listUsers(); + const authUser = existingUsers?.find((u) => u.email === telegramEmail); + + if (!authUser) { + return new Response( + JSON.stringify({ success: false, error: 'User not found. Please deposit first.' }), + { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const userId = authUser.id; + + // Validate input + if (!token || !amount || !walletAddress) { + return new Response( + JSON.stringify({ + success: false, + error: 'Missing required fields: token, amount, walletAddress', + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (!['HEZ', 'PEZ'].includes(token)) { + return new Response( + JSON.stringify({ success: false, error: 'Invalid token. Must be HEZ or PEZ' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (amount <= 0) { + return new Response( + JSON.stringify({ success: false, error: 'Amount must be greater than 0' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (amount < MIN_WITHDRAW[token]) { + return new Response( + JSON.stringify({ + success: false, + error: `Minimum withdrawal is ${MIN_WITHDRAW[token]} ${token}`, + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Validate wallet address format (Substrate SS58) + if (!walletAddress.match(/^5[A-HJ-NP-Za-km-z1-9]{47}$/)) { + return new Response( + JSON.stringify({ success: false, error: 'Invalid wallet address format' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const fee = WITHDRAW_FEE[token]; + const netAmount = amount - fee; + + if (netAmount <= 0) { + return new Response( + JSON.stringify({ success: false, error: 'Amount too small after fee deduction' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Check user's available balance + const { data: balanceData } = await serviceClient + .from('user_internal_balances') + .select('available_balance') + .eq('user_id', userId) + .eq('token', token) + .single(); + + if (!balanceData || balanceData.available_balance < amount) { + return new Response( + JSON.stringify({ + success: false, + error: 'Insufficient balance', + available: balanceData?.available_balance || 0, + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create withdrawal request using DB function (moves available -> locked) + const { data: requestResult, error: requestError } = await serviceClient.rpc( + 'request_withdraw', + { + p_user_id: userId, + p_token: token, + p_amount: amount, + p_wallet_address: walletAddress, + } + ); + + if (requestError || !requestResult?.success) { + return new Response( + JSON.stringify({ + success: false, + error: + requestResult?.error || requestError?.message || 'Failed to create withdrawal request', + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.log( + `Withdraw request created: TelegramID=${telegramId}, Amount=${amount} ${token}, Fee=${fee}, Net=${netAmount}, Wallet=${walletAddress}` + ); + + return new Response( + JSON.stringify({ + success: true, + requestId: requestResult.request_id, + amount, + fee, + netAmount, + token, + status: 'pending', + message: 'Withdrawal request created. Processing will begin shortly.', + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Edge function error:', error); + const origin = req.headers.get('origin'); + return new Response( + JSON.stringify({ success: false, error: 'Internal server error' }), + { status: 500, headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' } } + ); + } +});