feat: integrate P2P trading into Telegram mini app

- Add 8 Supabase edge functions for P2P operations (get-internal-balance,
  get-payment-methods, get-p2p-offers, accept-p2p-offer, get-p2p-trades,
  trade-action, p2p-messages, p2p-dispute)
- Add frontend P2P API layer (src/lib/p2p-api.ts)
- Add 8 P2P components (BalanceCard, OfferList, TradeModal, CreateOfferModal,
  TradeView, TradeChat, DisputeModal, P2P section)
- Embed P2P as internal section in App.tsx instead of external link
- Remove old P2PModal component
- Add ~70 P2P translation keys across all 6 languages
This commit is contained in:
2026-02-26 18:36:35 +03:00
parent 2fbbce192f
commit 31e768de45
26 changed files with 4938 additions and 163 deletions
+11 -49
View File
@@ -1,10 +1,7 @@
import { useState, lazy, Suspense, useCallback } from 'react';
import { useState, lazy, Suspense } from 'react';
import { Megaphone, MessageCircle, Gift, Wallet, Loader2, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { UpdateNotification } from '@/components/UpdateNotification';
import { P2PModal } from '@/components/P2PModal';
import { useAuth } from '@/contexts/AuthContext';
import { useWallet } from '@/contexts/WalletContext';
import { useTranslation } from '@/i18n';
// Lazy load sections for code splitting
@@ -20,6 +17,9 @@ const RewardsSection = lazy(() =>
const WalletSection = lazy(() =>
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
);
const P2PSection = lazy(() =>
import('@/sections/P2P').then((m) => ({ default: m.P2PSection }))
);
const CitizenPage = lazy(() =>
import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage }))
);
@@ -36,27 +36,22 @@ function SectionLoader() {
);
}
type Section = 'announcements' | 'forum' | 'rewards' | 'wallet';
type NavId = Section | 'p2p';
type Section = 'announcements' | 'forum' | 'rewards' | 'p2p' | 'wallet';
interface NavItem {
id: NavId;
id: Section;
icon: typeof Megaphone;
labelKey: string;
isExternal?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
{ id: 'forum', icon: MessageCircle, labelKey: 'nav.forum' },
{ id: 'rewards', icon: Gift, labelKey: 'nav.rewards' },
{ id: 'p2p', icon: ArrowLeftRight, labelKey: 'nav.p2p', isExternal: true },
{ id: 'p2p', icon: ArrowLeftRight, labelKey: 'nav.p2p' },
{ id: 'wallet', icon: Wallet, labelKey: 'nav.wallet' },
];
// P2P Web App URL - Mobile-optimized P2P
const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p';
// Check for standalone pages via URL query params or path (evaluated once at module level)
const PAGE_PARAM = new URLSearchParams(window.location.search).get('page');
const IS_CITIZEN_PAGE = PAGE_PARAM === 'citizen' || window.location.pathname === '/citizens';
@@ -84,42 +79,11 @@ export default function App() {
function MainApp() {
const [activeSection, setActiveSection] = useState<Section>('announcements');
const [showP2PModal, setShowP2PModal] = useState(false);
const { sessionToken } = useAuth();
const { address } = useWallet();
const { t } = useTranslation();
// Open P2P in popup with auth params
const openP2P = useCallback(() => {
window.Telegram?.WebApp.HapticFeedback.impactOccurred('medium');
// Build auth URL with session token
const params = new URLSearchParams();
if (sessionToken) {
params.set('session_token', sessionToken);
}
if (address) {
params.set('wallet', address);
}
params.set('from', 'miniapp');
const url = `${P2P_WEB_URL}?${params.toString()}`;
// Open in new window/tab
window.open(url, '_blank');
}, [sessionToken, address]);
const handleNavClick = (item: NavItem) => {
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
if (item.isExternal) {
// P2P opens modal first
if (item.id === 'p2p') {
setShowP2PModal(true);
}
} else {
setActiveSection(item.id as Section);
}
setActiveSection(item.id);
};
return (
@@ -131,6 +95,7 @@ function MainApp() {
{activeSection === 'announcements' && <AnnouncementsSection />}
{activeSection === 'forum' && <ForumSection />}
{activeSection === 'rewards' && <RewardsSection />}
{activeSection === 'p2p' && <P2PSection />}
{activeSection === 'wallet' && <WalletSection />}
</Suspense>
</div>
@@ -139,15 +104,12 @@ function MainApp() {
{/* Update Notification */}
<UpdateNotification />
{/* P2P Modal */}
<P2PModal isOpen={showP2PModal} onClose={() => setShowP2PModal(false)} onOpenP2P={openP2P} />
{/* Bottom Navigation */}
<nav className="flex-shrink-0 bg-secondary/50 backdrop-blur-lg border-t border-border safe-area-bottom">
<div className="flex justify-around items-center h-16">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = !item.isExternal && activeSection === item.id;
const isActive = activeSection === item.id;
return (
<button
@@ -156,7 +118,7 @@ function MainApp() {
className={cn(
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
item.isExternal && 'text-cyan-400 hover:text-cyan-300'
item.id === 'p2p' && !isActive && 'text-cyan-400 hover:text-cyan-300'
)}
>
<Icon className={cn('w-5 h-5', isActive && 'scale-110')} />
-85
View File
@@ -1,85 +0,0 @@
import { X, ExternalLink, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/i18n';
interface P2PModalProps {
isOpen: boolean;
onClose: () => void;
onOpenP2P: () => void;
}
export function P2PModal({ isOpen, onClose, onOpenP2P }: P2PModalProps) {
const { t, isRTL } = useTranslation();
if (!isOpen) return null;
const handleOpenP2P = () => {
onOpenP2P();
onClose();
};
// Access steps array directly - t() only works for string values
// We need to get the steps from the translation as individual indexed items
const steps: string[] = [];
for (let i = 0; i < 4; i++) {
const step = t(`p2p.steps.${i}`);
if (step !== `p2p.steps.${i}`) steps.push(step);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div
className={cn(
'relative w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.title')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">{t('p2p.subtitle')}</p>
{/* First Time Info */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="text-sm font-medium text-amber-400">{t('p2p.firstTime')}</p>
<ol className="space-y-1.5">
{steps.map((step, i) => (
<li key={i} className="text-xs text-amber-200/80 flex gap-2">
<span className="font-semibold text-amber-400">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Note */}
<p className="text-xs text-muted-foreground">{t('p2p.note')}</p>
</div>
{/* Action Button */}
<div className="p-4 pt-0">
<button
onClick={handleOpenP2P}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-cyan-500 hover:bg-cyan-600 text-white font-medium rounded-xl transition-colors"
>
<ExternalLink className="w-5 h-5" />
{t('p2p.button')}
</button>
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react';
import { Wallet, Lock, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getInternalBalance, type InternalBalance } from '@/lib/p2p-api';
interface BalanceCardProps {
onRefresh?: () => void;
}
export function BalanceCard({ onRefresh }: BalanceCardProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [balances, setBalances] = useState<InternalBalance[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchBalances = async () => {
if (!sessionToken) return;
try {
const data = await getInternalBalance(sessionToken);
setBalances(data);
} catch (err) {
console.error('Failed to fetch balances:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchBalances();
}, [sessionToken]);
const handleRefresh = () => {
hapticImpact('light');
setRefreshing(true);
fetchBalances();
onRefresh?.();
};
const formatBalance = (val: number) => {
return val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
};
return (
<div
className={cn(
'bg-gradient-to-br from-cyan-500/20 to-blue-600/20 rounded-2xl border border-cyan-500/30 p-4',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Wallet className="w-5 h-5 text-cyan-400" />
<h3 className="text-sm font-semibold text-foreground">{t('p2p.internalBalance')}</h3>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 rounded-full hover:bg-muted/50 transition-colors"
>
<RefreshCw className={cn('w-4 h-4 text-muted-foreground', refreshing && 'animate-spin')} />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
</div>
) : balances.length === 0 ? (
<div className="text-center py-3">
<p className="text-xs text-muted-foreground">{t('p2p.noBalance')}</p>
</div>
) : (
<div className="space-y-2">
{balances.map((bal) => (
<div key={bal.token} className="bg-card/50 rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-foreground">{bal.token}</span>
<span className="text-sm font-bold text-foreground">
{formatBalance(bal.available_balance + bal.locked_balance)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Wallet className="w-3 h-3" />
{t('p2p.available')}: {formatBalance(bal.available_balance)}
</span>
<span className="flex items-center gap-1">
<Lock className="w-3 h-3" />
{t('p2p.locked')}: {formatBalance(bal.locked_balance)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { createP2POffer, getPaymentMethods, getInternalBalance, type PaymentMethod } from '@/lib/p2p-api';
interface CreateOfferModalProps {
isOpen: boolean;
onClose: () => void;
onOfferCreated: () => void;
}
const FIAT_CURRENCIES = ['TRY', 'IQD', 'IRR', 'EUR', 'USD'];
const TOKENS: ('HEZ' | 'PEZ')[] = ['HEZ', 'PEZ'];
const TIME_LIMITS = [15, 30, 45, 60, 90, 120];
export function CreateOfferModal({ isOpen, onClose, onOfferCreated }: CreateOfferModalProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [adType, setAdType] = useState<'buy' | 'sell'>('sell');
const [token, setToken] = useState<'HEZ' | 'PEZ'>('HEZ');
const [amountCrypto, setAmountCrypto] = useState('');
const [fiatCurrency, setFiatCurrency] = useState('TRY');
const [fiatAmount, setFiatAmount] = useState('');
const [paymentMethodId, setPaymentMethodId] = useState('');
const [paymentDetails, setPaymentDetails] = useState('');
const [minOrder, setMinOrder] = useState('');
const [maxOrder, setMaxOrder] = useState('');
const [timeLimit, setTimeLimit] = useState(30);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [availableBalance, setAvailableBalance] = useState(0);
useEffect(() => {
if (!isOpen || !sessionToken) return;
getPaymentMethods(sessionToken, fiatCurrency)
.then(setPaymentMethods)
.catch(console.error);
}, [isOpen, sessionToken, fiatCurrency]);
useEffect(() => {
if (!isOpen || !sessionToken) return;
getInternalBalance(sessionToken)
.then((bals) => {
const bal = bals.find((b) => b.token === token);
setAvailableBalance(bal?.available_balance || 0);
})
.catch(console.error);
}, [isOpen, sessionToken, token]);
if (!isOpen) return null;
const numAmount = parseFloat(amountCrypto) || 0;
const numFiat = parseFloat(fiatAmount) || 0;
const pricePerUnit = numAmount > 0 && numFiat > 0 ? numFiat / numAmount : 0;
const isValid =
numAmount > 0 &&
numFiat > 0 &&
paymentMethodId &&
(adType === 'buy' || numAmount <= availableBalance);
const handleCreate = async () => {
if (!sessionToken || !isValid) return;
setError('');
setLoading(true);
hapticImpact('medium');
try {
await createP2POffer({
sessionToken,
token,
amountCrypto: numAmount,
fiatCurrency,
fiatAmount: numFiat,
paymentMethodId,
paymentDetailsEncrypted: btoa(paymentDetails),
minOrderAmount: parseFloat(minOrder) || undefined,
maxOrderAmount: parseFloat(maxOrder) || undefined,
timeLimitMinutes: timeLimit,
adType,
});
hapticNotification('success');
onOfferCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create offer');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-t-3xl border-t border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.createOffer')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3 max-h-[70vh] overflow-y-auto">
{/* Ad Type Toggle */}
<div className="flex rounded-xl bg-muted p-1">
<button
onClick={() => setAdType('sell')}
className={cn(
'flex-1 py-2 text-sm font-medium rounded-lg transition-colors',
adType === 'sell' ? 'bg-red-500 text-white' : 'text-muted-foreground'
)}
>
{t('p2p.sell')}
</button>
<button
onClick={() => setAdType('buy')}
className={cn(
'flex-1 py-2 text-sm font-medium rounded-lg transition-colors',
adType === 'buy' ? 'bg-green-500 text-white' : 'text-muted-foreground'
)}
>
{t('p2p.buy')}
</button>
</div>
{/* Token + Currency */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.token')}</label>
<select
value={token}
onChange={(e) => setToken(e.target.value as 'HEZ' | 'PEZ')}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{TOKENS.map((tk) => (
<option key={tk} value={tk}>{tk}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.fiatCurrency')}</label>
<select
value={fiatCurrency}
onChange={(e) => setFiatCurrency(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{FIAT_CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Amount Crypto */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t('p2p.amount')} ({token})
{adType === 'sell' && (
<span className="text-cyan-400 ml-1">
({t('p2p.available')}: {availableBalance.toLocaleString()})
</span>
)}
</label>
<input
type="number"
value={amountCrypto}
onChange={(e) => setAmountCrypto(e.target.value)}
placeholder="0.00"
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
{/* Fiat Amount */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.fiatTotal')} ({fiatCurrency})</label>
<input
type="number"
value={fiatAmount}
onChange={(e) => setFiatAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
{pricePerUnit > 0 && (
<p className="text-xs text-cyan-400 mt-1">
{t('p2p.pricePerUnit')}: {pricePerUnit.toLocaleString(undefined, { maximumFractionDigits: 2 })} {fiatCurrency}/{token}
</p>
)}
</div>
{/* Payment Method */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.paymentMethod')}</label>
<select
value={paymentMethodId}
onChange={(e) => setPaymentMethodId(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
<option value="">{t('p2p.selectPaymentMethod')}</option>
{paymentMethods.map((pm) => (
<option key={pm.id} value={pm.id}>{pm.method_name}</option>
))}
</select>
</div>
{/* Payment Details */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.paymentDetails')}</label>
<textarea
value={paymentDetails}
onChange={(e) => setPaymentDetails(e.target.value)}
placeholder={t('p2p.paymentDetailsPlaceholder')}
rows={2}
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none resize-none text-sm"
/>
</div>
{/* Order Limits */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.minOrder')}</label>
<input
type="number"
value={minOrder}
onChange={(e) => setMinOrder(e.target.value)}
placeholder="0"
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.maxOrder')}</label>
<input
type="number"
value={maxOrder}
onChange={(e) => setMaxOrder(e.target.value)}
placeholder={amountCrypto || '0'}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
</div>
{/* Time Limit */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.timeLimit')}</label>
<select
value={timeLimit}
onChange={(e) => setTimeLimit(parseInt(e.target.value))}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{TIME_LIMITS.map((tl) => (
<option key={tl} value={tl}>{tl} {t('p2p.minutes')}</option>
))}
</select>
</div>
{/* Balance Warning */}
{adType === 'sell' && numAmount > availableBalance && (
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 rounded-xl p-3">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t('p2p.insufficientBalance')}</span>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Action */}
<div className="p-4 pt-0">
<button
onClick={handleCreate}
disabled={!isValid || loading}
className={cn(
'w-full py-3 rounded-xl font-medium text-white transition-colors',
isValid && !loading
? 'bg-cyan-500 hover:bg-cyan-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.createOffer')}
</button>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { openDispute } from '@/lib/p2p-api';
interface DisputeModalProps {
isOpen: boolean;
onClose: () => void;
tradeId: string;
onDisputeOpened: () => void;
}
const DISPUTE_CATEGORIES = [
'payment_not_received',
'wrong_amount',
'fake_payment_proof',
'other',
] as const;
export function DisputeModal({ isOpen, onClose, tradeId, onDisputeOpened }: DisputeModalProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [category, setCategory] = useState<string>('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!isOpen) return null;
const isValid = category && reason.trim().length >= 10;
const handleSubmit = async () => {
if (!sessionToken || !isValid) return;
setError('');
setLoading(true);
hapticImpact('heavy');
try {
await openDispute(sessionToken, tradeId, reason.trim(), category);
hapticNotification('warning');
onDisputeOpened();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open dispute');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-semibold text-foreground">{t('p2p.openDispute')}</h2>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Warning */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-3">
<p className="text-xs text-amber-400">{t('p2p.disputeWarning')}</p>
</div>
{/* Category */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.disputeCategory')}</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
<option value="">{t('p2p.selectCategory')}</option>
{DISPUTE_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{t(`p2p.dispute.${cat}`)}</option>
))}
</select>
</div>
{/* Reason */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.disputeReason')}</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('p2p.disputeReasonPlaceholder')}
rows={3}
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-amber-500 focus:outline-none resize-none text-sm"
maxLength={2000}
/>
<p className="text-xs text-muted-foreground mt-1">
{reason.length}/2000 ({t('p2p.minChars', { count: '10' })})
</p>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 p-4 pt-0">
<button
onClick={onClose}
className="flex-1 py-3 bg-muted hover:bg-muted/80 text-foreground font-medium rounded-xl transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!isValid || loading}
className={cn(
'flex-1 py-3 font-medium rounded-xl transition-colors text-white',
isValid && !loading
? 'bg-amber-500 hover:bg-amber-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.submitDispute')}
</button>
</div>
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Star, Clock, ChevronDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getP2POffers, type P2POffer } from '@/lib/p2p-api';
interface OfferListProps {
adType: 'buy' | 'sell';
onAcceptOffer: (offer: P2POffer) => void;
}
const FIAT_CURRENCIES = ['TRY', 'IQD', 'IRR', 'EUR', 'USD'];
const TOKENS = ['HEZ', 'PEZ'];
export function OfferList({ adType, onAcceptOffer }: OfferListProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [offers, setOffers] = useState<P2POffer[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [selectedCurrency, setSelectedCurrency] = useState<string>('');
const [selectedToken, setSelectedToken] = useState<string>('');
const [showFilters, setShowFilters] = useState(false);
const limit = 20;
const fetchOffers = async (p = 1) => {
if (!sessionToken) return;
setLoading(true);
try {
const result = await getP2POffers({
sessionToken,
adType,
token: selectedToken || undefined,
fiatCurrency: selectedCurrency || undefined,
page: p,
limit,
});
setOffers(result.offers);
setTotal(result.total);
setPage(p);
} catch (err) {
console.error('Failed to fetch offers:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOffers(1);
}, [sessionToken, adType, selectedCurrency, selectedToken]);
const handleAccept = (offer: P2POffer) => {
hapticImpact('medium');
onAcceptOffer(offer);
};
const totalPages = Math.ceil(total / limit);
return (
<div className={cn(isRTL && 'direction-rtl')} dir={isRTL ? 'rtl' : 'ltr'}>
{/* Filters Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-1 text-xs text-muted-foreground mb-2 hover:text-foreground transition-colors"
>
<ChevronDown className={cn('w-3 h-3 transition-transform', showFilters && 'rotate-180')} />
{t('p2p.filters')}
</button>
{showFilters && (
<div className="flex flex-wrap gap-2 mb-3">
<select
value={selectedToken}
onChange={(e) => setSelectedToken(e.target.value)}
className="text-xs bg-muted rounded-lg px-2 py-1.5 border border-border text-foreground"
>
<option value="">{t('p2p.allTokens')}</option>
{TOKENS.map((tk) => (
<option key={tk} value={tk}>{tk}</option>
))}
</select>
<select
value={selectedCurrency}
onChange={(e) => setSelectedCurrency(e.target.value)}
className="text-xs bg-muted rounded-lg px-2 py-1.5 border border-border text-foreground"
>
<option value="">{t('p2p.allCurrencies')}</option>
{FIAT_CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
{/* Offer List */}
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
) : offers.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.noOffers')}</p>
</div>
) : (
<div className="space-y-2">
{offers.map((offer) => (
<div
key={offer.id}
className="bg-card rounded-xl border border-border p-3 space-y-2"
>
{/* Top: Token + Price */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-foreground">{offer.token}</span>
<span className="text-xs text-muted-foreground">/ {offer.fiat_currency}</span>
</div>
<span className="text-sm font-bold text-cyan-400">
{offer.price_per_unit?.toLocaleString(undefined, { maximumFractionDigits: 2 })} {offer.fiat_currency}
</span>
</div>
{/* Middle: Amount + Limits */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t('p2p.available')}: {offer.remaining_amount} {offer.token}
</span>
<span>
{offer.min_order_amount && `${t('p2p.min')}: ${offer.min_order_amount}`}
{offer.min_order_amount && offer.max_order_amount && ' - '}
{offer.max_order_amount && `${t('p2p.max')}: ${offer.max_order_amount}`}
</span>
</div>
{/* Bottom: Reputation + Payment + Action */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs">
{offer.seller_reputation && (
<span className="flex items-center gap-1 text-amber-400">
<Star className="w-3 h-3 fill-current" />
{offer.seller_reputation.completed_trades} {t('p2p.trades')}
</span>
)}
{offer.payment_method_name && (
<span className="text-muted-foreground">{offer.payment_method_name}</span>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<Clock className="w-3 h-3" />
{offer.time_limit_minutes}m
</span>
</div>
<button
onClick={() => handleAccept(offer)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
adType === 'buy'
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
)}
>
{adType === 'buy' ? t('p2p.buy') : t('p2p.sell')}
</button>
</div>
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => fetchOffers(page - 1)}
disabled={page <= 1}
className="px-3 py-1 text-xs bg-muted rounded-lg disabled:opacity-50"
>
{t('p2p.prev')}
</button>
<span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
<button
onClick={() => fetchOffers(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 text-xs bg-muted rounded-lg disabled:opacity-50"
>
{t('p2p.next')}
</button>
</div>
)}
</div>
)}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef } from 'react';
import { X, Send } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getTradeMessages, sendTradeMessage, type P2PMessage } from '@/lib/p2p-api';
interface TradeChatProps {
tradeId: string;
onClose: () => void;
}
export function TradeChat({ tradeId, onClose }: TradeChatProps) {
const { sessionToken, user } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [messages, setMessages] = useState<P2PMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const userId = user?.id;
const fetchMessages = async () => {
if (!sessionToken) return;
try {
const msgs = await getTradeMessages(sessionToken, tradeId);
setMessages(msgs);
} catch (err) {
console.error('Failed to fetch messages:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMessages();
const interval = setInterval(fetchMessages, 5000);
return () => clearInterval(interval);
}, [sessionToken, tradeId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!sessionToken || !newMessage.trim() || sending) return;
hapticImpact('light');
setSending(true);
try {
await sendTradeMessage(sessionToken, tradeId, newMessage.trim());
setNewMessage('');
fetchMessages();
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Header */}
<div className={cn(
'flex items-center justify-between p-4 border-b border-border safe-area-top',
isRTL && 'direction-rtl'
)} dir={isRTL ? 'rtl' : 'ltr'}>
<h2 className="text-lg font-semibold text-foreground">{t('p2p.chat')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : messages.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.noMessages')}</p>
</div>
) : (
messages.map((msg) => {
const isOwn = msg.sender_id === userId;
const isSystem = msg.message_type === 'system';
return (
<div
key={msg.id}
className={cn(
'flex',
isSystem ? 'justify-center' :
isOwn ? 'justify-end' : 'justify-start'
)}
>
{isSystem ? (
<span className="text-xs text-muted-foreground bg-muted/50 rounded-full px-3 py-1">
{msg.message}
</span>
) : (
<div
className={cn(
'max-w-[75%] rounded-2xl px-3 py-2',
isOwn
? 'bg-cyan-500 text-white rounded-br-sm'
: 'bg-muted text-foreground rounded-bl-sm'
)}
>
<p className="text-sm break-words">{msg.message}</p>
<p className={cn(
'text-[10px] mt-1',
isOwn ? 'text-white/60' : 'text-muted-foreground'
)}>
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className={cn(
'flex items-center gap-2 p-4 border-t border-border safe-area-bottom',
isRTL && 'direction-rtl'
)} dir={isRTL ? 'rtl' : 'ltr'}>
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('p2p.messagePlaceholder')}
className="flex-1 bg-muted rounded-full px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 border border-border focus:border-cyan-500 focus:outline-none"
maxLength={2000}
/>
<button
onClick={handleSend}
disabled={!newMessage.trim() || sending}
className="p-2.5 bg-cyan-500 hover:bg-cyan-600 rounded-full transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4 text-white" />
</button>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { X, AlertTriangle } 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 { acceptP2POffer, type P2POffer } from '@/lib/p2p-api';
interface TradeModalProps {
isOpen: boolean;
onClose: () => void;
offer: P2POffer | null;
onTradeCreated: (tradeId: string) => void;
}
export function TradeModal({ isOpen, onClose, offer, onTradeCreated }: TradeModalProps) {
const { sessionToken } = useAuth();
const { address } = useWallet();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [amount, setAmount] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!isOpen || !offer) return null;
const numAmount = parseFloat(amount) || 0;
const fiatTotal = numAmount > 0 && offer.price_per_unit
? numAmount * offer.price_per_unit
: 0;
const isValid =
numAmount > 0 &&
numAmount <= offer.remaining_amount &&
(!offer.min_order_amount || numAmount >= offer.min_order_amount) &&
(!offer.max_order_amount || numAmount <= offer.max_order_amount);
const handleAccept = async () => {
if (!sessionToken || !address || !isValid) return;
setError('');
setLoading(true);
hapticImpact('medium');
try {
const result = await acceptP2POffer({
sessionToken,
offerId: offer.id,
amount: numAmount,
buyerWallet: address,
});
hapticNotification('success');
onTradeCreated(result.tradeId);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to accept offer');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-t-3xl border-t border-border overflow-hidden animate-in slide-in-from-bottom',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.acceptOffer')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Offer Summary */}
<div className="bg-muted/50 rounded-xl p-3 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.token')}</span>
<span className="font-medium text-foreground">{offer.token}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.pricePerUnit')}</span>
<span className="font-medium text-foreground">
{offer.price_per_unit?.toLocaleString()} {offer.fiat_currency}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.available')}</span>
<span className="font-medium text-foreground">
{offer.remaining_amount} {offer.token}
</span>
</div>
{offer.payment_method_name && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.paymentMethod')}</span>
<span className="font-medium text-foreground">{offer.payment_method_name}</span>
</div>
)}
</div>
{/* Amount Input */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.amount')} ({offer.token})</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={`${offer.min_order_amount || 0} - ${offer.max_order_amount || offer.remaining_amount}`}
className="w-full bg-muted rounded-xl px-4 py-3 text-foreground placeholder:text-muted-foreground/50 border border-border focus:border-cyan-500 focus:outline-none"
step="any"
min={offer.min_order_amount || 0}
max={offer.max_order_amount || offer.remaining_amount}
/>
<button
onClick={() => setAmount(String(offer.max_order_amount || offer.remaining_amount))}
className="text-xs text-cyan-400 mt-1 hover:text-cyan-300"
>
{t('p2p.max')}: {offer.max_order_amount || offer.remaining_amount} {offer.token}
</button>
</div>
{/* Fiat Total */}
{fiatTotal > 0 && (
<div className="bg-cyan-500/10 border border-cyan-500/30 rounded-xl p-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">{t('p2p.fiatTotal')}</span>
<span className="text-lg font-bold text-cyan-400">
{fiatTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} {offer.fiat_currency}
</span>
</div>
</div>
)}
{/* Time Limit Warning */}
<div className="flex items-start gap-2 text-xs text-amber-400/80">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t('p2p.timeLimitWarning', { minutes: String(offer.time_limit_minutes) })}</span>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Action */}
<div className="p-4 pt-0">
<button
onClick={handleAccept}
disabled={!isValid || loading}
className={cn(
'w-full py-3 rounded-xl font-medium text-white transition-colors',
isValid && !loading
? 'bg-green-500 hover:bg-green-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.confirmAccept')}
</button>
</div>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState, useEffect, useCallback } from 'react';
import { ArrowLeft, Clock, CheckCircle2, XCircle, AlertTriangle, MessageCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import {
getP2PTrades,
markTradePaid,
confirmTradePayment,
cancelTrade,
type P2PTrade,
} from '@/lib/p2p-api';
import { TradeChat } from './TradeChat';
import { DisputeModal } from './DisputeModal';
interface TradeViewProps {
tradeId?: string;
onBack: () => void;
}
const STATUS_CONFIG: Record<string, { color: string; icon: typeof Clock }> = {
pending: { color: 'text-amber-400', icon: Clock },
payment_sent: { color: 'text-blue-400', icon: Clock },
completed: { color: 'text-green-400', icon: CheckCircle2 },
cancelled: { color: 'text-red-400', icon: XCircle },
disputed: { color: 'text-orange-400', icon: AlertTriangle },
refunded: { color: 'text-gray-400', icon: XCircle },
};
export function TradeView({ tradeId, onBack }: TradeViewProps) {
const { sessionToken, user } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [trade, setTrade] = useState<P2PTrade | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showDispute, setShowDispute] = useState(false);
const [error, setError] = useState('');
const userId = user?.id;
const isBuyer = trade?.buyer_id === userId;
const isSeller = trade?.seller_id === userId;
const fetchTrade = useCallback(async () => {
if (!sessionToken || !tradeId) return;
try {
const trades = await getP2PTrades(sessionToken, 'all');
const found = trades.find((tr) => tr.id === tradeId);
if (found) setTrade(found);
} catch (err) {
console.error('Failed to fetch trade:', err);
} finally {
setLoading(false);
}
}, [sessionToken, tradeId]);
useEffect(() => {
fetchTrade();
const interval = setInterval(fetchTrade, 15000);
return () => clearInterval(interval);
}, [fetchTrade]);
// Countdown timer
const [timeRemaining, setTimeRemaining] = useState('');
useEffect(() => {
if (!trade) return;
const deadline = trade.status === 'pending' ? trade.payment_deadline :
trade.status === 'payment_sent' ? trade.confirmation_deadline : null;
if (!deadline) { setTimeRemaining(''); return; }
const update = () => {
const diff = new Date(deadline).getTime() - Date.now();
if (diff <= 0) { setTimeRemaining(t('p2p.expired')); return; }
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${mins}:${secs.toString().padStart(2, '0')}`);
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [trade, t]);
const handleMarkPaid = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('medium');
try {
await markTradePaid(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
const handleConfirm = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('heavy');
try {
await confirmTradePayment(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
const handleCancel = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('medium');
try {
await cancelTrade(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<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 py-8">
<p className="text-sm text-muted-foreground">{t('p2p.tradeNotFound')}</p>
<button onClick={onBack} className="text-cyan-400 text-sm mt-2">{t('common.back')}</button>
</div>
);
}
const statusConfig = STATUS_CONFIG[trade.status] || STATUS_CONFIG.pending;
const StatusIcon = statusConfig.icon;
return (
<div className={cn('space-y-4', isRTL && 'direction-rtl')} dir={isRTL ? 'rtl' : 'ltr'}>
{/* Header */}
<div className="flex items-center gap-3">
<button onClick={onBack} className="p-2 rounded-full hover:bg-muted transition-colors">
<ArrowLeft className="w-5 h-5 text-muted-foreground" />
</button>
<h2 className="text-lg font-semibold text-foreground">{t('p2p.tradeDetails')}</h2>
</div>
{/* Status Banner */}
<div className={cn('flex items-center gap-2 p-3 rounded-xl', `${statusConfig.color} bg-current/10`)}>
<StatusIcon className={cn('w-5 h-5', statusConfig.color)} />
<span className={cn('text-sm font-medium', statusConfig.color)}>
{t(`p2p.status.${trade.status}`)}
</span>
{timeRemaining && (
<span className={cn('text-xs ml-auto', statusConfig.color)}>
{t('p2p.timeRemaining')}: {timeRemaining}
</span>
)}
</div>
{/* Trade Info */}
<div className="bg-card rounded-xl border border-border p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.role')}</span>
<span className="font-medium text-foreground">
{isBuyer ? t('p2p.buyer') : t('p2p.seller')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.cryptoAmount')}</span>
<span className="font-medium text-foreground">
{trade.crypto_amount} {trade.token || ''}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.fiatAmount')}</span>
<span className="font-medium text-foreground">
{trade.fiat_amount?.toLocaleString()} {trade.fiat_currency || ''}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.pricePerUnit')}</span>
<span className="font-medium text-foreground">
{trade.price_per_unit?.toLocaleString()} {trade.fiat_currency || ''}
</span>
</div>
</div>
{/* Timeline */}
<div className="bg-card rounded-xl border border-border p-4 space-y-3">
<h3 className="text-sm font-medium text-foreground">{t('p2p.timeline')}</h3>
<TimelineStep
done
label={t('p2p.tradeCreated')}
time={trade.created_at}
/>
<TimelineStep
done={!!trade.buyer_marked_paid_at}
active={trade.status === 'pending'}
label={t('p2p.paymentSent')}
time={trade.buyer_marked_paid_at}
/>
<TimelineStep
done={!!trade.seller_confirmed_at}
active={trade.status === 'payment_sent'}
label={t('p2p.paymentConfirmed')}
time={trade.seller_confirmed_at}
/>
<TimelineStep
done={trade.status === 'completed'}
label={t('p2p.tradeCompleted')}
time={trade.completed_at}
/>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
{/* Action Buttons */}
<div className="space-y-2">
{/* Buyer: Mark as paid */}
{isBuyer && trade.status === 'pending' && (
<button
onClick={handleMarkPaid}
disabled={actionLoading}
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-xl transition-colors disabled:opacity-50"
>
{actionLoading ? t('common.loading') : t('p2p.markPaid')}
</button>
)}
{/* Seller: Confirm payment received */}
{isSeller && trade.status === 'payment_sent' && (
<button
onClick={handleConfirm}
disabled={actionLoading}
className="w-full py-3 bg-green-500 hover:bg-green-600 text-white font-medium rounded-xl transition-colors disabled:opacity-50"
>
{actionLoading ? t('common.loading') : t('p2p.confirmReceived')}
</button>
)}
{/* Cancel (buyer only, pending status) */}
{isBuyer && trade.status === 'pending' && (
<button
onClick={handleCancel}
disabled={actionLoading}
className="w-full py-3 bg-red-500/10 hover:bg-red-500/20 text-red-400 font-medium rounded-xl transition-colors disabled:opacity-50"
>
{t('p2p.cancelTrade')}
</button>
)}
{/* Chat */}
{['pending', 'payment_sent', 'disputed'].includes(trade.status) && (
<button
onClick={() => setShowChat(true)}
className="w-full py-3 bg-muted hover:bg-muted/80 text-foreground font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
<MessageCircle className="w-4 h-4" />
{t('p2p.chat')}
</button>
)}
{/* Dispute */}
{['pending', 'payment_sent'].includes(trade.status) && (
<button
onClick={() => setShowDispute(true)}
className="w-full py-2 text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
{t('p2p.openDispute')}
</button>
)}
</div>
{/* Chat Modal */}
{showChat && (
<TradeChat tradeId={trade.id} onClose={() => setShowChat(false)} />
)}
{/* Dispute Modal */}
<DisputeModal
isOpen={showDispute}
onClose={() => setShowDispute(false)}
tradeId={trade.id}
onDisputeOpened={() => { setShowDispute(false); fetchTrade(); }}
/>
</div>
);
}
// Timeline step sub-component
function TimelineStep({ done, active, label, time }: {
done?: boolean;
active?: boolean;
label: string;
time?: string | null;
}) {
return (
<div className="flex items-center gap-3">
<div
className={cn(
'w-3 h-3 rounded-full border-2 flex-shrink-0',
done ? 'bg-green-400 border-green-400' :
active ? 'border-cyan-400 animate-pulse' :
'border-muted-foreground/30'
)}
/>
<div className="flex-1">
<p className={cn('text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>{label}</p>
{time && (
<p className="text-xs text-muted-foreground">
{new Date(time).toLocaleString()}
</p>
)}
</div>
</div>
);
}
+88 -6
View File
@@ -304,16 +304,98 @@ const ar: Translations = {
p2p: {
title: 'تبادل P2P',
subtitle: 'تداول العملات الرقمية بين الأفراد',
subtitle: 'تداول العملات المشفرة بين الأفراد',
firstTime: 'أول مرة تستخدم P2P؟',
steps: [
'انقر على الزر أدناه لفتح تطبيق الويب',
'أنشئ حسابًا أو سجّل الدخول',
'أكمل عملية إعداد P2P',
'بعد الإعداد، يمكنك الوصول إلى P2P مباشرة',
'اضغط الزر أدناه لفتح تطبيق الويب',
'أنشئ حساب أو سجل الدخول',
'أكمل عملية الإعداد',
'بعد الإعداد، يمكنك الوصول مباشرة إلى P2P',
],
note: 'سيُفتح تطبيق الويب في نافذة جديدة. أكمل عملية التسجيل هناك.',
note: 'سيفتح تطبيق الويب في نافذة جديدة.',
button: 'فتح منصة P2P',
buy: 'شراء',
sell: 'بيع',
myAds: 'إعلاناتي',
myTrades: 'صفقاتي',
internalBalance: 'رصيد P2P',
available: 'متاح',
locked: 'مقفل',
totalBalance: 'المجموع',
noBalance: 'لا يوجد رصيد بعد. قم بالإيداع لبدء التداول.',
createOffer: 'إنشاء عرض',
acceptOffer: 'قبول العرض',
confirmAccept: 'تأكيد وقبول',
noOffers: 'لا توجد عروض متاحة',
filters: 'الفلاتر',
allTokens: 'جميع التوكنات',
allCurrencies: 'جميع العملات',
prev: 'السابق',
next: 'التالي',
min: 'الحد الأدنى',
max: 'الحد الأقصى',
trades: 'صفقات',
token: 'توكن',
fiatCurrency: 'العملة',
pricePerUnit: 'سعر الوحدة',
amount: 'المبلغ',
fiatTotal: 'إجمالي الفيات',
paymentMethod: 'طريقة الدفع',
selectPaymentMethod: 'اختر طريقة الدفع',
paymentDetails: 'تفاصيل الدفع',
paymentDetailsPlaceholder: 'أدخل تفاصيل الدفع (حساب بنكي، إلخ)',
minOrder: 'الحد الأدنى للطلب',
maxOrder: 'الحد الأقصى للطلب',
timeLimit: 'المهلة الزمنية',
minutes: 'دقيقة',
insufficientBalance: 'رصيد غير كافي',
timeLimitWarning: 'لديك {minutes} دقيقة لإتمام الدفع بعد القبول.',
tradeDetails: 'تفاصيل الصفقة',
tradeNotFound: 'الصفقة غير موجودة',
role: 'الدور',
buyer: 'المشتري',
seller: 'البائع',
cryptoAmount: 'مبلغ العملة المشفرة',
fiatAmount: 'مبلغ الفيات',
timeline: 'الجدول الزمني',
tradeCreated: 'تم إنشاء الصفقة',
paymentSent: 'تم إرسال الدفع',
paymentConfirmed: 'تم تأكيد الدفع',
tradeCompleted: 'تمت الصفقة',
timeRemaining: 'الوقت المتبقي',
expired: 'منتهية الصلاحية',
markPaid: 'قمت بالدفع',
confirmReceived: 'تأكيد استلام الدفع',
cancelTrade: 'إلغاء الصفقة',
status: {
open: 'مفتوح',
paused: 'متوقف',
locked: 'مقفل',
completed: 'مكتمل',
cancelled: 'ملغي',
pending: 'معلق',
payment_sent: 'تم إرسال الدفع',
disputed: 'متنازع عليه',
refunded: 'مسترد',
},
chat: 'الدردشة',
noMessages: 'لا توجد رسائل بعد',
messagePlaceholder: 'اكتب رسالة...',
openDispute: 'فتح نزاع',
disputeWarning: 'فتح نزاع سيجمد الصفقة. سيقوم مشرف بمراجعتها.',
disputeCategory: 'الفئة',
selectCategory: 'اختر الفئة',
disputeReason: 'السبب',
disputeReasonPlaceholder: 'صف المشكلة بالتفصيل (١٠ أحرف على الأقل)',
minChars: 'على الأقل {count} أحرف',
submitDispute: 'إرسال النزاع',
dispute: {
payment_not_received: 'لم يتم استلام الدفع',
wrong_amount: 'مبلغ خاطئ',
fake_payment_proof: 'إثبات دفع مزور',
other: 'أخرى',
},
noTrades: 'لا توجد صفقات بعد',
},
update: {
+89 -7
View File
@@ -306,16 +306,98 @@ const ckb: Translations = {
p2p: {
title: 'ئاڵوگۆڕی P2P',
subtitle: 'ئاڵوگۆڕی کریپتۆ نێوان کەسەکان',
subtitle: 'بازرگانیی کریپتۆ لە نێوان کەسەکاندا',
firstTime: 'یەکەم جارە P2P بەکاردەهێنیت؟',
steps: [
'کلیک لەسەر دوگمەی خوارەوە بکە بۆ کردنەوەی وێب ئەپ',
'ئەکاونتێک دروستبکە یان بچۆ ژوورەوە',
'ڕێکخستنی P2P تەواو بکە',
'دوای ڕێکخستن، دەتوانیت ڕاستەوخۆ دەستت بە P2P بگات',
'دوگمەی خوارەوە دابگرە بۆ کردنەوەی وێب ئەپ',
'هەژمارێک دروست بکە یان بچۆ ژوورەوە',
'پرۆسەی دامەزراندن تەواو بکە',
'دوای دامەزراندن، دەتوانیت ڕاستەوخۆ دەستت بگات بە P2P',
],
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە. ڕێکخستنەکە لەوێ تەواو بکە.',
button: 'کردنەوەی پلاتفۆرمی P2P',
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە.',
button: 'پلاتفۆرمی P2P بکەرەوە',
buy: 'بکڕە',
sell: 'بفرۆشە',
myAds: 'ڕیکلامەکانم',
myTrades: 'بازرگانییەکانم',
internalBalance: 'باڵانسی P2P',
available: 'بەردەست',
locked: 'قفڵکراو',
totalBalance: 'کۆی گشتی',
noBalance: 'هێشتا باڵانس نییە. بۆ دەستپێکردن پارە دابنێ.',
createOffer: 'پێشنیار دروست بکە',
acceptOffer: 'پێشنیار قبوڵ بکە',
confirmAccept: 'دڵنیابکەرەوە و قبوڵ بکە',
noOffers: 'هیچ پێشنیارێک نییە',
filters: 'فلتەرەکان',
allTokens: 'هەموو تۆکنەکان',
allCurrencies: 'هەموو دراوەکان',
prev: 'پێشوو',
next: 'دواتر',
min: 'کەمترین',
max: 'زۆرترین',
trades: 'بازرگانی',
token: 'تۆکن',
fiatCurrency: 'دراو',
pricePerUnit: 'نرخی یەکە',
amount: 'بڕ',
fiatTotal: 'کۆی فیات',
paymentMethod: 'شێوازی پارەدان',
selectPaymentMethod: 'شێوازی پارەدان هەڵبژێرە',
paymentDetails: 'وردەکاری پارەدان',
paymentDetailsPlaceholder: 'وردەکاری پارەدانت بنووسە (هەژماری بانکی، هتد)',
minOrder: 'کەمترین داواکاری',
maxOrder: 'زۆرترین داواکاری',
timeLimit: 'سنووری کات',
minutes: 'خولەک',
insufficientBalance: 'باڵانس بەس نییە',
timeLimitWarning: 'دوای قبوڵکردن {minutes} خولەکت هەیە بۆ تەواوکردنی پارەدان.',
tradeDetails: 'وردەکاری بازرگانی',
tradeNotFound: 'بازرگانی نەدۆزرایەوە',
role: 'ڕۆڵ',
buyer: 'کڕیار',
seller: 'فرۆشیار',
cryptoAmount: 'بڕی کریپتۆ',
fiatAmount: 'بڕی فیات',
timeline: 'هێڵی کات',
tradeCreated: 'بازرگانی دروستکرا',
paymentSent: 'پارەدان نێردرا',
paymentConfirmed: 'پارەدان پشتڕاستکرایەوە',
tradeCompleted: 'بازرگانی تەواوبوو',
timeRemaining: 'کاتی ماوە',
expired: 'بەسەرچوو',
markPaid: 'پارەم دا',
confirmReceived: 'وەرگرتنی پارە پشتڕاست بکەرەوە',
cancelTrade: 'بازرگانی هەڵبوەشێنەرەوە',
status: {
open: 'کراوە',
paused: 'وەستێنراو',
locked: 'قفڵکراو',
completed: 'تەواوبوو',
cancelled: 'هەڵوەشێنراوە',
pending: 'چاوەڕوانی',
payment_sent: 'پارەدان نێردرا',
disputed: 'ناکۆکی',
refunded: 'گەڕێنراوە',
},
chat: 'چات',
noMessages: 'هێشتا پەیام نییە',
messagePlaceholder: 'پەیامێک بنووسە...',
openDispute: 'ناکۆکی بکەرەوە',
disputeWarning: 'کردنەوەی ناکۆکی بازرگانی دەسەڕینێت. مۆدەراتۆرێک پشکنینی دەکات.',
disputeCategory: 'پۆل',
selectCategory: 'پۆلێک هەڵبژێرە',
disputeReason: 'هۆکار',
disputeReasonPlaceholder: 'کێشەکە بە وردی باسبکە (لانیکەم ١٠ پیت)',
minChars: 'لانیکەم {count} پیت',
submitDispute: 'ناکۆکی بنێرە',
dispute: {
payment_not_received: 'پارەدان وەرنەگیرا',
wrong_amount: 'بڕی هەڵە',
fake_payment_proof: 'بەڵگەی پارەدانی ساختە',
other: 'هی تر',
},
noTrades: 'هێشتا بازرگانی نییە',
},
update: {
+82
View File
@@ -315,6 +315,88 @@ const en: Translations = {
],
note: 'The web app will open in a new window. Complete the registration process there.',
button: 'Open P2P Platform',
buy: 'Buy',
sell: 'Sell',
myAds: 'My Ads',
myTrades: 'My Trades',
internalBalance: 'P2P Balance',
available: 'Available',
locked: 'Locked',
totalBalance: 'Total',
noBalance: 'No balance yet. Deposit to start trading.',
createOffer: 'Create Offer',
acceptOffer: 'Accept Offer',
confirmAccept: 'Confirm & Accept',
noOffers: 'No offers available',
filters: 'Filters',
allTokens: 'All Tokens',
allCurrencies: 'All Currencies',
prev: 'Previous',
next: 'Next',
min: 'Min',
max: 'Max',
trades: 'trades',
token: 'Token',
fiatCurrency: 'Currency',
pricePerUnit: 'Price per unit',
amount: 'Amount',
fiatTotal: 'Fiat Total',
paymentMethod: 'Payment Method',
selectPaymentMethod: 'Select payment method',
paymentDetails: 'Payment Details',
paymentDetailsPlaceholder: 'Enter your payment details (bank account, etc.)',
minOrder: 'Min Order',
maxOrder: 'Max Order',
timeLimit: 'Time Limit',
minutes: 'min',
insufficientBalance: 'Insufficient balance',
timeLimitWarning: 'You have {minutes} minutes to complete the payment after accepting.',
tradeDetails: 'Trade Details',
tradeNotFound: 'Trade not found',
role: 'Role',
buyer: 'Buyer',
seller: 'Seller',
cryptoAmount: 'Crypto Amount',
fiatAmount: 'Fiat Amount',
timeline: 'Timeline',
tradeCreated: 'Trade Created',
paymentSent: 'Payment Sent',
paymentConfirmed: 'Payment Confirmed',
tradeCompleted: 'Trade Completed',
timeRemaining: 'Time remaining',
expired: 'Expired',
markPaid: 'I Have Paid',
confirmReceived: 'Confirm Payment Received',
cancelTrade: 'Cancel Trade',
status: {
open: 'Open',
paused: 'Paused',
locked: 'Locked',
completed: 'Completed',
cancelled: 'Cancelled',
pending: 'Pending',
payment_sent: 'Payment Sent',
disputed: 'Disputed',
refunded: 'Refunded',
},
chat: 'Chat',
noMessages: 'No messages yet',
messagePlaceholder: 'Type a message...',
openDispute: 'Open Dispute',
disputeWarning: 'Opening a dispute will freeze the trade. A moderator will review the case.',
disputeCategory: 'Category',
selectCategory: 'Select category',
disputeReason: 'Reason',
disputeReasonPlaceholder: 'Describe the issue in detail (min 10 characters)',
minChars: 'Min {count} characters',
submitDispute: 'Submit Dispute',
dispute: {
payment_not_received: 'Payment not received',
wrong_amount: 'Wrong amount',
fake_payment_proof: 'Fake payment proof',
other: 'Other',
},
noTrades: 'No trades yet',
},
update: {
+89 -7
View File
@@ -305,16 +305,98 @@ const fa: Translations = {
p2p: {
title: 'صرافی P2P',
subtitle: 'معامله رمزارز به صورت همتا به همتا',
firstTime: 'اولین بار است که از P2P استفاده می‌کنید؟',
subtitle: 'معامله کریپتو نفر به نفر',
firstTime: 'اولین بار از P2P استفاده می‌کنید؟',
steps: [
'روی دکمه زیر کلیک کنید تا برنامه وب باز شود',
'یک حساب بسازید یا وارد شوید',
'فرآیند راه‌اندازی P2P را تکمیل کنید',
'پس از راه‌اندازی، می‌توانید مستقیماً به P2P دسترسی پیدا کنید',
'دکمه زیر را برای باز کردن وب اپلیکیشن بزنید',
'حساب بسازید یا وارد شوید',
'فرایند راه‌اندازی را کامل کنید',
'بعد از راه‌اندازی، مستقیم به P2P دسترسی دارید',
],
note: 'برنامه وب در پنجره جدیدی باز می‌شود. فرآیند ثبت‌نام را در آنجا تکمیل کنید.',
note: 'وب اپلیکیشن در پنجره جدید باز می‌شود.',
button: 'باز کردن پلتفرم P2P',
buy: 'خرید',
sell: 'فروش',
myAds: 'آگهی‌های من',
myTrades: 'معاملات من',
internalBalance: 'موجودی P2P',
available: 'قابل استفاده',
locked: 'قفل شده',
totalBalance: 'مجموع',
noBalance: 'هنوز موجودی ندارید. برای شروع واریز کنید.',
createOffer: 'ایجاد پیشنهاد',
acceptOffer: 'قبول پیشنهاد',
confirmAccept: 'تایید و قبول',
noOffers: 'پیشنهادی موجود نیست',
filters: 'فیلترها',
allTokens: 'همه توکن‌ها',
allCurrencies: 'همه ارزها',
prev: 'قبلی',
next: 'بعدی',
min: 'حداقل',
max: 'حداکثر',
trades: 'معامله',
token: 'توکن',
fiatCurrency: 'ارز',
pricePerUnit: 'قیمت واحد',
amount: 'مقدار',
fiatTotal: 'جمع فیات',
paymentMethod: 'روش پرداخت',
selectPaymentMethod: 'روش پرداخت انتخاب کنید',
paymentDetails: 'اطلاعات پرداخت',
paymentDetailsPlaceholder: 'اطلاعات پرداخت خود را وارد کنید (حساب بانکی و غیره)',
minOrder: 'حداقل سفارش',
maxOrder: 'حداکثر سفارش',
timeLimit: 'محدودیت زمانی',
minutes: 'دقیقه',
insufficientBalance: 'موجودی ناکافی',
timeLimitWarning: 'بعد از قبول {minutes} دقیقه برای تکمیل پرداخت فرصت دارید.',
tradeDetails: 'جزئیات معامله',
tradeNotFound: 'معامله پیدا نشد',
role: 'نقش',
buyer: 'خریدار',
seller: 'فروشنده',
cryptoAmount: 'مقدار کریپتو',
fiatAmount: 'مقدار فیات',
timeline: 'جدول زمانی',
tradeCreated: 'معامله ایجاد شد',
paymentSent: 'پرداخت ارسال شد',
paymentConfirmed: 'پرداخت تایید شد',
tradeCompleted: 'معامله تکمیل شد',
timeRemaining: 'زمان باقیمانده',
expired: 'منقضی شده',
markPaid: 'پرداخت کردم',
confirmReceived: 'تایید دریافت پرداخت',
cancelTrade: 'لغو معامله',
status: {
open: 'باز',
paused: 'متوقف',
locked: 'قفل شده',
completed: 'تکمیل شده',
cancelled: 'لغو شده',
pending: 'در انتظار',
payment_sent: 'پرداخت ارسال شد',
disputed: 'اعتراض شده',
refunded: 'بازپرداخت شده',
},
chat: 'چت',
noMessages: 'هنوز پیامی نیست',
messagePlaceholder: 'پیام بنویسید...',
openDispute: 'اعتراض باز کنید',
disputeWarning: 'باز کردن اعتراض معامله را متوقف می‌کند. یک مدیر بررسی خواهد کرد.',
disputeCategory: 'دسته‌بندی',
selectCategory: 'دسته‌بندی انتخاب کنید',
disputeReason: 'دلیل',
disputeReasonPlaceholder: 'مشکل را با جزئیات توضیح دهید (حداقل ۱۰ کاراکتر)',
minChars: 'حداقل {count} کاراکتر',
submitDispute: 'ارسال اعتراض',
dispute: {
payment_not_received: 'پرداخت دریافت نشد',
wrong_amount: 'مبلغ اشتباه',
fake_payment_proof: 'مدرک پرداخت جعلی',
other: 'سایر',
},
noTrades: 'هنوز معامله‌ای نیست',
},
update: {
+84 -2
View File
@@ -316,7 +316,7 @@ const krd: Translations = {
},
p2p: {
title: 'P2P Dan\u00fbstandin',
title: 'Danûstandina P2P',
subtitle: 'Dan\u00fbstandina kr\u00eeto di navbera kesan de',
firstTime: 'Cara yekem P2P bikar t\u00eenin?',
steps: [
@@ -326,7 +326,89 @@ const krd: Translations = {
'Pi\u015ft\u00ee sazkirin\u00ea, h\u00fbn dikarin rasterast bigih\u00eejin P2P',
],
note: 'Malpera web\u00ea di pencereyek n\u00fb de vedibe. P\u00eav ajoya qeydk\u00eerin\u00ea li wir temam bikin.',
button: 'P2P Veke',
button: 'Platforma P2P Veke',
buy: 'Bikire',
sell: 'Bifiroşe',
myAds: 'Reklamên Min',
myTrades: 'Bazirganiyên Min',
internalBalance: 'Balansa P2P',
available: 'Berdest',
locked: 'Qefilkirî',
totalBalance: 'Tevahî',
noBalance: 'Hê balans tune. Ji bo destpêkirinê depo bikin.',
createOffer: 'Pêşniyar Biafirîne',
acceptOffer: 'Pêşniyar Bipejirîne',
confirmAccept: 'Bipejirîne & Qebûl Bike',
noOffers: 'Pêşniyar tune ne',
filters: 'Fîltre',
allTokens: 'Hemû Token',
allCurrencies: 'Hemû Dirav',
prev: 'Berê',
next: 'Pêş',
min: 'Kêmtirîn',
max: 'Herî zêde',
trades: 'bazirganî',
token: 'Token',
fiatCurrency: 'Dirav',
pricePerUnit: 'Bihayê yekîneyê',
amount: 'Miqdar',
fiatTotal: 'Bihayê Tevahî',
paymentMethod: 'Rêbaza Dravdanê',
selectPaymentMethod: 'Rêbaza dravdanê hilbijêrin',
paymentDetails: 'Agahdariya Dravdanê',
paymentDetailsPlaceholder: 'Agahdariya dravdanê binivîsin (hesabê bankê, hwd.)',
minOrder: 'Kêmtirîn Sifarîş',
maxOrder: 'Zêdetirîn Sifarîş',
timeLimit: 'Sînorê Demê',
minutes: 'deq',
insufficientBalance: 'Balansa têr nîne',
timeLimitWarning: 'Piştî qebûlkirinê {minutes} deqîqe hene ku dravdanê temam bikin.',
tradeDetails: 'Hûrgiliyên Bazirganiyê',
tradeNotFound: 'Bazirganî nehat dîtin',
role: 'Rol',
buyer: 'Kiriyar',
seller: 'Firotkar',
cryptoAmount: 'Mîqdara Krîpto',
fiatAmount: 'Mîqdara Fîat',
timeline: 'Demnîgar',
tradeCreated: 'Bazirganî Hat Afirandin',
paymentSent: 'Dravdan Hat Şandin',
paymentConfirmed: 'Dravdan Hat Pejirandin',
tradeCompleted: 'Bazirganî Qediya',
timeRemaining: 'Dema mayî',
expired: 'Dema wê derbas bû',
markPaid: 'Min Drav Da',
confirmReceived: 'Dravdanê Bipejirîne',
cancelTrade: 'Bazirganiyê Betal Bike',
status: {
open: 'Vekirî',
paused: 'Sekinandî',
locked: 'Qefilkirî',
completed: 'Qediyayî',
cancelled: 'Betalkirî',
pending: 'Li Bendê',
payment_sent: 'Dravdan Şandî',
disputed: 'Nakokî',
refunded: 'Vegerandî',
},
chat: 'Sohbet',
noMessages: 'Hê peyam tune',
messagePlaceholder: 'Peyamekê binivîsin...',
openDispute: 'Nakokî Veke',
disputeWarning: 'Vekirina nakokiyê bazirganiyê dicemidîne. Moderatorek ê diyar bike.',
disputeCategory: 'Kategorî',
selectCategory: 'Kategorîyê hilbijêrin',
disputeReason: 'Sedem',
disputeReasonPlaceholder: 'Pirsgirêkê bi hûrgulî binivîsin (herî kêm 10 tîp)',
minChars: 'Herî kêm {count} tîp',
submitDispute: 'Nakokî Bişîne',
dispute: {
payment_not_received: 'Dravdan nehat wergirtin',
wrong_amount: 'Mîqdara çewt',
fake_payment_proof: 'Belgeya dravdanê ya sexte',
other: 'Yên din',
},
noTrades: 'Hê bazirganî tune',
},
update: {
+88 -6
View File
@@ -305,16 +305,98 @@ const tr: Translations = {
p2p: {
title: 'P2P Borsa',
subtitle: 'Eşler arası kripto ticareti',
firstTime: "P2P'yi ilk kez mi kullanıyorsunuz?",
subtitle: 'Eşler arası kripto alım-satım',
firstTime: 'P2P\'yi ilk kez mi kullanıyorsun?',
steps: [
'Web uygulamasını açmak için aşağıdaki düğmeye tıklayın',
'Aşağıdaki butona tıklayarak web uygulamasınıın',
'Hesap oluşturun veya giriş yapın',
'P2P kurulum sürecini tamamlayın',
"Kurulumdan sonra P2P'ye doğrudan erişebilirsiniz",
'Kurulum sürecini tamamlayın',
'Kurulumdan sonra P2P\'ye doğrudan erişebilirsiniz',
],
note: 'Web uygulaması yeni bir pencerede açılacak. Kayıt işlemini orada tamamlayın.',
note: 'Web uygulaması yeni bir pencerede açılacaktır.',
button: 'P2P Platformunu Aç',
buy: 'Al',
sell: 'Sat',
myAds: 'İlanlarım',
myTrades: 'İşlemlerim',
internalBalance: 'P2P Bakiye',
available: 'Kullanılabilir',
locked: 'Kilitli',
totalBalance: 'Toplam',
noBalance: 'Henüz bakiye yok. İşlem yapmak için para yatırın.',
createOffer: 'İlan Oluştur',
acceptOffer: 'Teklifi Kabul Et',
confirmAccept: 'Onayla ve Kabul Et',
noOffers: 'Mevcut teklif yok',
filters: 'Filtreler',
allTokens: 'Tüm Tokenler',
allCurrencies: 'Tüm Para Birimleri',
prev: 'Önceki',
next: 'Sonraki',
min: 'Min',
max: 'Maks',
trades: 'işlem',
token: 'Token',
fiatCurrency: 'Para Birimi',
pricePerUnit: 'Birim fiyat',
amount: 'Miktar',
fiatTotal: 'Fiat Toplamı',
paymentMethod: 'Ödeme Yöntemi',
selectPaymentMethod: 'Ödeme yöntemi seçin',
paymentDetails: 'Ödeme Bilgileri',
paymentDetailsPlaceholder: 'Ödeme bilgilerinizi girin (banka hesabı vb.)',
minOrder: 'Min Sipariş',
maxOrder: 'Maks Sipariş',
timeLimit: 'Süre Limiti',
minutes: 'dk',
insufficientBalance: 'Yetersiz bakiye',
timeLimitWarning: 'Kabul ettikten sonra ödemeyi tamamlamak için {minutes} dakikanız var.',
tradeDetails: 'İşlem Detayları',
tradeNotFound: 'İşlem bulunamadı',
role: 'Rol',
buyer: 'Alıcı',
seller: 'Satıcı',
cryptoAmount: 'Kripto Miktarı',
fiatAmount: 'Fiat Miktarı',
timeline: 'Zaman Çizelgesi',
tradeCreated: 'İşlem Oluşturuldu',
paymentSent: 'Ödeme Gönderildi',
paymentConfirmed: 'Ödeme Onaylandı',
tradeCompleted: 'İşlem Tamamlandı',
timeRemaining: 'Kalan süre',
expired: 'Süresi doldu',
markPaid: 'Ödeme Yaptım',
confirmReceived: 'Ödeme Alındığını Onayla',
cancelTrade: 'İşlemi İptal Et',
status: {
open: 'Açık',
paused: 'Duraklatılmış',
locked: 'Kilitli',
completed: 'Tamamlandı',
cancelled: 'İptal Edildi',
pending: 'Beklemede',
payment_sent: 'Ödeme Gönderildi',
disputed: 'İtirazlı',
refunded: 'İade Edildi',
},
chat: 'Sohbet',
noMessages: 'Henüz mesaj yok',
messagePlaceholder: 'Mesaj yazın...',
openDispute: 'İtiraz Aç',
disputeWarning: 'İtiraz açmak işlemi dondurur. Bir moderatör inceleyecektir.',
disputeCategory: 'Kategori',
selectCategory: 'Kategori seçin',
disputeReason: 'Sebep',
disputeReasonPlaceholder: 'Sorunu ayrıntılı açıklayın (min 10 karakter)',
minChars: 'En az {count} karakter',
submitDispute: 'İtirazı Gönder',
dispute: {
payment_not_received: 'Ödeme alınmadı',
wrong_amount: 'Yanlış miktar',
fake_payment_proof: 'Sahte ödeme kanıtı',
other: 'Diğer',
},
noTrades: 'Henüz işlem yok',
},
update: {
+93 -1
View File
@@ -308,7 +308,7 @@ export interface Translations {
trc20FeeWarning: string;
};
// P2P Modal
// P2P Section
p2p: {
title: string;
subtitle: string;
@@ -316,6 +316,98 @@ export interface Translations {
steps: string[];
note: string;
button: string;
// Tabs
buy: string;
sell: string;
myAds: string;
myTrades: string;
// Balance
internalBalance: string;
available: string;
locked: string;
totalBalance: string;
noBalance: string;
// Offers
createOffer: string;
acceptOffer: string;
confirmAccept: string;
noOffers: string;
filters: string;
allTokens: string;
allCurrencies: string;
prev: string;
next: string;
min: string;
max: string;
trades: string;
// Offer form
token: string;
fiatCurrency: string;
pricePerUnit: string;
amount: string;
fiatTotal: string;
paymentMethod: string;
selectPaymentMethod: string;
paymentDetails: string;
paymentDetailsPlaceholder: string;
minOrder: string;
maxOrder: string;
timeLimit: string;
minutes: string;
insufficientBalance: string;
timeLimitWarning: string;
// Trade
tradeDetails: string;
tradeNotFound: string;
role: string;
buyer: string;
seller: string;
cryptoAmount: string;
fiatAmount: string;
timeline: string;
tradeCreated: string;
paymentSent: string;
paymentConfirmed: string;
tradeCompleted: string;
timeRemaining: string;
expired: string;
// Trade actions
markPaid: string;
confirmReceived: string;
cancelTrade: string;
// Status
status: {
open: string;
paused: string;
locked: string;
completed: string;
cancelled: string;
pending: string;
payment_sent: string;
disputed: string;
refunded: string;
};
// Chat
chat: string;
noMessages: string;
messagePlaceholder: string;
// Dispute
openDispute: string;
disputeWarning: string;
disputeCategory: string;
selectCategory: string;
disputeReason: string;
disputeReasonPlaceholder: string;
minChars: string;
submitDispute: string;
dispute: {
payment_not_received: string;
wrong_amount: string;
fake_payment_proof: string;
other: string;
};
// No data
noTrades: string;
};
// Update Notification
+328
View File
@@ -0,0 +1,328 @@
import { supabase } from './supabase';
// ─── Types ───────────────────────────────────────────────────
export interface InternalBalance {
token: string;
available_balance: number;
locked_balance: number;
total: number;
}
export interface PaymentMethod {
id: string;
currency: string;
country: string;
method_name: string;
method_type: string;
logo_url: string | null;
fields: Record<string, unknown>;
min_trade_amount: number;
max_trade_amount: number | null;
processing_time_minutes: number;
}
export interface P2POffer {
id: string;
seller_id: string;
token: string;
amount_crypto: number;
fiat_currency: string;
fiat_amount: number;
price_per_unit: number;
payment_method_id: string;
payment_method_name?: string;
min_order_amount: number | null;
max_order_amount: number | null;
time_limit_minutes: number;
status: string;
remaining_amount: number;
ad_type: 'buy' | 'sell';
created_at: string;
expires_at: string;
seller_reputation?: SellerReputation | null;
}
export interface SellerReputation {
completed_trades: number;
total_trades: number;
reputation_score: number;
trust_level: string;
avg_confirmation_time_minutes: number | null;
}
export interface P2PTrade {
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;
status: string;
payment_deadline: string;
confirmation_deadline: string | null;
buyer_marked_paid_at: string | null;
seller_confirmed_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
// Joined from offer
token?: string;
fiat_currency?: string;
ad_type?: string;
payment_method_name?: string;
}
export interface P2PMessage {
id: string;
trade_id: string;
sender_id: string;
message: string;
message_type: string;
is_read: boolean;
created_at: string;
}
export interface P2PDispute {
id: string;
trade_id: string;
opened_by: string;
reason: string;
category: string;
status: string;
created_at: string;
}
// ─── API Helper ──────────────────────────────────────────────
async function callEdgeFunction<T>(
functionName: string,
body: Record<string, unknown>
): Promise<T> {
const { data, error } = await supabase.functions.invoke(functionName, { body });
if (error) {
let msg = error.message || 'Unknown error';
if (error.context?.body) {
try {
const parsed = JSON.parse(error.context.body);
if (parsed.error) msg = parsed.error;
} catch {
if (typeof error.context.body === 'string') msg = error.context.body;
}
}
throw new Error(msg);
}
if (data?.error) {
throw new Error(data.error);
}
return data as T;
}
// ─── Balance ─────────────────────────────────────────────────
export async function getInternalBalance(
sessionToken: string
): Promise<InternalBalance[]> {
const result = await callEdgeFunction<{ success: boolean; balances: InternalBalance[] }>(
'get-internal-balance',
{ sessionToken }
);
return result.balances || [];
}
// ─── Payment Methods ─────────────────────────────────────────
export async function getPaymentMethods(
sessionToken: string,
currency?: string
): Promise<PaymentMethod[]> {
const result = await callEdgeFunction<{ success: boolean; methods: PaymentMethod[] }>(
'get-payment-methods',
{ sessionToken, currency }
);
return result.methods || [];
}
// ─── Offers ──────────────────────────────────────────────────
interface GetOffersParams {
sessionToken: string;
adType?: 'buy' | 'sell';
token?: string;
fiatCurrency?: string;
page?: number;
limit?: number;
}
interface GetOffersResult {
offers: P2POffer[];
total: number;
page: number;
limit: number;
}
export async function getP2POffers(params: GetOffersParams): Promise<GetOffersResult> {
const result = await callEdgeFunction<{ success: boolean } & GetOffersResult>(
'get-p2p-offers',
params as unknown as Record<string, unknown>
);
return { offers: result.offers || [], total: result.total || 0, page: result.page || 1, limit: result.limit || 20 };
}
export async function getMyOffers(sessionToken: string): Promise<P2POffer[]> {
const result = await callEdgeFunction<{ success: boolean; offers: P2POffer[] }>(
'get-my-offers',
{ sessionToken }
);
return result.offers || [];
}
// ─── Accept Offer ────────────────────────────────────────────
interface AcceptOfferParams {
sessionToken: string;
offerId: string;
amount: number;
buyerWallet: string;
}
export async function acceptP2POffer(params: AcceptOfferParams): Promise<{ tradeId: string; trade: P2PTrade }> {
return callEdgeFunction<{ success: boolean; tradeId: string; trade: P2PTrade }>(
'accept-p2p-offer',
params as unknown as Record<string, unknown>
);
}
// ─── Create Offer ────────────────────────────────────────────
interface CreateOfferParams {
sessionToken: string;
token: 'HEZ' | 'PEZ';
amountCrypto: number;
fiatCurrency: string;
fiatAmount: number;
paymentMethodId: string;
paymentDetailsEncrypted: string;
minOrderAmount?: number;
maxOrderAmount?: number;
timeLimitMinutes?: number;
adType?: 'buy' | 'sell';
}
export async function createP2POffer(params: CreateOfferParams): Promise<{ offerId: string; offer: P2POffer }> {
const result = await callEdgeFunction<{ success: boolean; offer_id: string; offer: P2POffer }>(
'create-offer-telegram',
params as unknown as Record<string, unknown>
);
return { offerId: result.offer_id, offer: result.offer };
}
// ─── Trades ──────────────────────────────────────────────────
export async function getP2PTrades(
sessionToken: string,
status?: 'active' | 'completed' | 'all'
): Promise<P2PTrade[]> {
const result = await callEdgeFunction<{ success: boolean; trades: P2PTrade[] }>(
'get-p2p-trades',
{ sessionToken, status }
);
return result.trades || [];
}
// ─── Trade Actions ───────────────────────────────────────────
export async function markTradePaid(sessionToken: string, tradeId: string): Promise<P2PTrade> {
const result = await callEdgeFunction<{ success: boolean; trade: P2PTrade }>(
'trade-action',
{ sessionToken, tradeId, action: 'mark_paid' }
);
return result.trade;
}
export async function confirmTradePayment(sessionToken: string, tradeId: string): Promise<P2PTrade> {
const result = await callEdgeFunction<{ success: boolean; trade: P2PTrade }>(
'trade-action',
{ sessionToken, tradeId, action: 'confirm' }
);
return result.trade;
}
export async function cancelTrade(sessionToken: string, tradeId: string, reason?: string): Promise<P2PTrade> {
const result = await callEdgeFunction<{ success: boolean; trade: P2PTrade }>(
'trade-action',
{ sessionToken, tradeId, action: 'cancel', payload: { reason } }
);
return result.trade;
}
export async function rateTrade(
sessionToken: string,
tradeId: string,
rating: number,
review?: string
): Promise<void> {
await callEdgeFunction<{ success: boolean }>(
'trade-action',
{ sessionToken, tradeId, action: 'rate', payload: { rating, review } }
);
}
// ─── Messages ────────────────────────────────────────────────
export async function sendTradeMessage(
sessionToken: string,
tradeId: string,
message: string
): Promise<string> {
const result = await callEdgeFunction<{ success: boolean; messageId: string }>(
'p2p-messages',
{ sessionToken, action: 'send', tradeId, message }
);
return result.messageId;
}
export async function getTradeMessages(
sessionToken: string,
tradeId: string
): Promise<P2PMessage[]> {
const result = await callEdgeFunction<{ success: boolean; messages: P2PMessage[] }>(
'p2p-messages',
{ sessionToken, action: 'list', tradeId }
);
return result.messages || [];
}
// ─── Disputes ────────────────────────────────────────────────
export async function openDispute(
sessionToken: string,
tradeId: string,
reason: string,
category: string
): Promise<P2PDispute> {
const result = await callEdgeFunction<{ success: boolean; dispute: P2PDispute }>(
'p2p-dispute',
{ sessionToken, action: 'open', tradeId, reason, category }
);
return result.dispute;
}
export async function addDisputeEvidence(
sessionToken: string,
tradeId: string,
evidenceUrl: string,
evidenceType: string,
description?: string
): Promise<void> {
await callEdgeFunction<{ success: boolean }>(
'p2p-dispute',
{ sessionToken, action: 'add_evidence', tradeId, evidenceUrl, evidenceType, description }
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useState, useEffect, useCallback } 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 { 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';
type Tab = 'buy' | 'sell' | 'myAds' | 'myTrades';
export function P2PSection() {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [activeTab, setActiveTab] = useState<Tab>('buy');
const [selectedOffer, setSelectedOffer] = useState<P2POffer | null>(null);
const [showCreateOffer, setShowCreateOffer] = useState(false);
const [activeTradeId, setActiveTradeId] = useState<string | null>(null);
// My ads / trades state
const [myOffers, setMyOffers] = useState<P2POffer[]>([]);
const [myTrades, setMyTrades] = useState<P2PTrade[]>([]);
const [myDataLoading, setMyDataLoading] = useState(false);
const fetchMyOffers = useCallback(async () => {
if (!sessionToken) return;
try {
const offers = await getMyOffers(sessionToken);
setMyOffers(offers);
} catch (err) {
console.error('Failed to fetch my offers:', err);
}
}, [sessionToken]);
const fetchMyTrades = useCallback(async () => {
if (!sessionToken) return;
try {
const trades = await getP2PTrades(sessionToken, 'all');
setMyTrades(trades);
} catch (err) {
console.error('Failed to fetch my trades:', err);
}
}, [sessionToken]);
useEffect(() => {
if (activeTab === 'myAds') {
setMyDataLoading(true);
fetchMyOffers().finally(() => setMyDataLoading(false));
} else if (activeTab === 'myTrades') {
setMyDataLoading(true);
fetchMyTrades().finally(() => setMyDataLoading(false));
}
}, [activeTab, fetchMyOffers, fetchMyTrades]);
const handleTabChange = (tab: Tab) => {
hapticImpact('light');
setActiveTab(tab);
};
const handleAcceptOffer = (offer: P2POffer) => {
setSelectedOffer(offer);
};
const handleTradeCreated = (tradeId: string) => {
setSelectedOffer(null);
setActiveTradeId(tradeId);
};
const handleOfferCreated = () => {
setShowCreateOffer(false);
if (activeTab === 'myAds') fetchMyOffers();
};
// If viewing a specific trade, show TradeView
if (activeTradeId) {
return (
<div className="h-full overflow-y-auto p-4 safe-area-top">
<TradeView
tradeId={activeTradeId}
onBack={() => setActiveTradeId(null)}
/>
</div>
);
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'buy', label: t('p2p.buy') },
{ id: 'sell', label: t('p2p.sell') },
{ id: 'myAds', label: t('p2p.myAds') },
{ id: 'myTrades', label: t('p2p.myTrades') },
];
const statusColor = (s: string) => {
switch (s) {
case 'open': return 'text-green-400';
case 'pending': return 'text-amber-400';
case 'payment_sent': return 'text-blue-400';
case 'completed': return 'text-green-400';
case 'cancelled': case 'refunded': return 'text-red-400';
case 'disputed': return 'text-orange-400';
default: return 'text-muted-foreground';
}
};
return (
<div
className={cn('h-full flex flex-col', isRTL && 'direction-rtl')}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="p-4 safe-area-top space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-foreground">{t('p2p.title')}</h1>
<button
onClick={() => { hapticImpact('medium'); setShowCreateOffer(true); }}
className="flex items-center gap-1 px-3 py-1.5 bg-cyan-500 hover:bg-cyan-600 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
{t('p2p.createOffer')}
</button>
</div>
{/* Balance Card */}
<BalanceCard />
{/* Tabs */}
<div className="flex rounded-xl bg-muted p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
'flex-1 py-2 text-xs font-medium rounded-lg transition-colors',
activeTab === tab.id
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground'
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{/* Buy / Sell Tabs → OfferList */}
{(activeTab === 'buy' || activeTab === 'sell') && (
<OfferList
adType={activeTab === 'buy' ? 'sell' : 'buy'}
onAcceptOffer={handleAcceptOffer}
/>
)}
{/* My Ads */}
{activeTab === 'myAds' && (
myDataLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
) : myOffers.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.noOffers')}</p>
<button
onClick={() => setShowCreateOffer(true)}
className="text-cyan-400 text-sm mt-2 hover:text-cyan-300"
>
{t('p2p.createOffer')}
</button>
</div>
) : (
<div className="space-y-2">
{myOffers.map((offer) => (
<div key={offer.id} className="bg-card rounded-xl border border-border p-3 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn(
'text-xs font-medium px-2 py-0.5 rounded-full',
offer.ad_type === 'sell' ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'
)}>
{offer.ad_type === 'sell' ? t('p2p.sell') : t('p2p.buy')}
</span>
<span className="text-sm font-bold text-foreground">{offer.token}</span>
</div>
<span className={cn('text-xs font-medium', statusColor(offer.status))}>
{t(`p2p.status.${offer.status}`)}
</span>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{offer.remaining_amount}/{offer.amount_crypto} {offer.token}</span>
<span>{offer.price_per_unit?.toLocaleString()} {offer.fiat_currency}</span>
</div>
</div>
))}
</div>
)
)}
{/* My Trades */}
{activeTab === 'myTrades' && (
myDataLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
) : myTrades.length === 0 ? (
<div className="text-center py-8">
<History className="w-8 h-8 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">{t('p2p.noTrades')}</p>
</div>
) : (
<div className="space-y-2">
{myTrades.map((trade) => (
<button
key={trade.id}
onClick={() => { hapticImpact('light'); setActiveTradeId(trade.id); }}
className="w-full text-left bg-card rounded-xl border border-border p-3 space-y-1 hover:border-cyan-500/50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-foreground">
{trade.crypto_amount} {trade.token || ''}
</span>
<span className={cn('text-xs font-medium', statusColor(trade.status))}>
{t(`p2p.status.${trade.status}`)}
</span>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{trade.fiat_amount?.toLocaleString()} {trade.fiat_currency || ''}</span>
<span>{new Date(trade.created_at).toLocaleDateString()}</span>
</div>
</button>
))}
</div>
)
)}
</div>
{/* Trade Accept Modal */}
<TradeModal
isOpen={!!selectedOffer}
onClose={() => setSelectedOffer(null)}
offer={selectedOffer}
onTradeCreated={handleTradeCreated}
/>
{/* Create Offer Modal */}
<CreateOfferModal
isOpen={showCreateOffer}
onClose={() => setShowCreateOffer(false)}
onOfferCreated={handleOfferCreated}
/>
</div>
);
}
@@ -0,0 +1,223 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface AcceptP2POfferRequest {
sessionToken: string;
offerId: string;
amount: number;
buyerWallet: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: AcceptP2POfferRequest = await req.json();
const { sessionToken, offerId, amount, buyerWallet } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate required fields
if (!offerId || !amount || !buyerWallet) {
return new Response(JSON.stringify({ error: 'Missing required fields: offerId, amount, buyerWallet' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (amount <= 0) {
return new Response(JSON.stringify({ error: 'Amount must be greater than 0' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Call the accept_p2p_offer RPC function
const { data: rpcResult, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: userId,
p_buyer_wallet: buyerWallet,
p_amount: amount,
});
if (rpcError) {
console.error('Accept offer RPC error:', rpcError);
return new Response(
JSON.stringify({ error: 'Failed to accept offer: ' + rpcError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Parse the JSON result
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error || 'Failed to accept offer' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Log to p2p_audit_log
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'accept_offer',
entity_type: 'trade',
entity_id: result.trade_id,
details: {
offer_id: offerId,
amount,
buyer_wallet: buyerWallet,
},
});
return new Response(
JSON.stringify({
success: true,
tradeId: result.trade_id,
trade: result.trade || result,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
@@ -0,0 +1,182 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface GetInternalBalanceRequest {
sessionToken: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: GetInternalBalanceRequest = await req.json();
const { sessionToken } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Query user_internal_balances for this user
const { data: balances, error: balanceError } = await supabase
.from('user_internal_balances')
.select('token, available_balance, locked_balance')
.eq('user_id', userId);
if (balanceError) {
console.error('Balance query error:', balanceError);
return new Response(
JSON.stringify({ error: 'Failed to fetch balances: ' + balanceError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Add computed total field
const enrichedBalances = (balances || []).map((b: { token: string; available_balance: number; locked_balance: number }) => ({
...b,
total: Number(b.available_balance) + Number(b.locked_balance),
}));
return new Response(
JSON.stringify({
success: true,
balances: enrichedBalances,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
+268
View File
@@ -0,0 +1,268 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface GetP2POffersRequest {
sessionToken: string;
adType?: 'buy' | 'sell';
token?: 'HEZ' | 'PEZ';
fiatCurrency?: string;
page?: number;
limit?: number;
}
// 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: GetP2POffersRequest = await req.json();
const {
sessionToken,
adType,
token,
fiatCurrency,
page = 1,
limit = 20,
} = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Calculate pagination
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), 100);
const offset = (safePage - 1) * safeLimit;
// Build query for p2p_fiat_offers
let query = supabase
.from('p2p_fiat_offers')
.select('*', { count: 'exact' })
.eq('status', 'open')
.gt('remaining_amount', 0)
.gt('expires_at', new Date().toISOString())
.neq('seller_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + safeLimit - 1);
// Apply optional filters
if (adType) {
query = query.eq('ad_type', adType);
}
if (token) {
query = query.eq('token', token);
}
if (fiatCurrency) {
query = query.eq('fiat_currency', fiatCurrency);
}
const { data: offers, error: offersError, count } = await query;
if (offersError) {
console.error('Offers query error:', offersError);
return new Response(
JSON.stringify({ error: 'Failed to fetch offers: ' + offersError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
if (!offers || offers.length === 0) {
return new Response(
JSON.stringify({
success: true,
offers: [],
total: 0,
page: safePage,
limit: safeLimit,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Collect unique payment method IDs and seller IDs
const paymentMethodIds = [...new Set(offers.map((o) => o.payment_method_id).filter(Boolean))];
const sellerIds = [...new Set(offers.map((o) => o.seller_id).filter(Boolean))];
// Fetch payment method names in a separate query
let paymentMethodMap: Record<string, string> = {};
if (paymentMethodIds.length > 0) {
const { data: paymentMethods } = await supabase
.from('payment_methods')
.select('id, method_name')
.in('id', paymentMethodIds);
if (paymentMethods) {
paymentMethodMap = Object.fromEntries(
paymentMethods.map((pm) => [pm.id, pm.method_name])
);
}
}
// Fetch seller reputation data in a separate query
let reputationMap: Record<string, any> = {};
if (sellerIds.length > 0) {
const { data: reputations } = await supabase
.from('p2p_reputation')
.select('user_id, total_trades, completed_trades, reputation_score, trust_level, avg_confirmation_time_minutes')
.in('user_id', sellerIds);
if (reputations) {
reputationMap = Object.fromEntries(
reputations.map((r) => [r.user_id, r])
);
}
}
// Enrich offers with payment method name and seller reputation
const enrichedOffers = offers.map((offer) => ({
...offer,
payment_method_name: paymentMethodMap[offer.payment_method_id] || null,
seller_reputation: reputationMap[offer.seller_id] || null,
}));
return new Response(
JSON.stringify({
success: true,
offers: enrichedOffers,
total: count || 0,
page: safePage,
limit: safeLimit,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
+212
View File
@@ -0,0 +1,212 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface GetP2PTradesRequest {
sessionToken: string;
status?: 'active' | 'completed' | 'all';
}
// 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: GetP2PTradesRequest = await req.json();
const { sessionToken, status } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Build query: trades where user is seller OR buyer
let query = supabase
.from('p2p_fiat_trades')
.select('*')
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
.order('created_at', { ascending: false });
// Apply status filter
if (status === 'active') {
query = query.in('status', ['pending', 'payment_sent', 'disputed']);
} else if (status === 'completed') {
query = query.in('status', ['completed', 'cancelled', 'refunded']);
}
// 'all' or not provided: no additional filter
const { data: trades, error: tradesError } = await query;
if (tradesError) {
console.error('Fetch trades error:', tradesError);
return new Response(
JSON.stringify({ error: 'Failed to fetch trades: ' + tradesError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Fetch offer details for each trade
const offerIds = [...new Set((trades || []).map((t) => t.offer_id).filter(Boolean))];
let offersMap: Record<string, { token: string; fiat_currency: string; ad_type: string }> = {};
if (offerIds.length > 0) {
const { data: offers, error: offersError } = await supabase
.from('p2p_fiat_offers')
.select('id, token, fiat_currency, ad_type')
.in('id', offerIds);
if (!offersError && offers) {
offersMap = Object.fromEntries(
offers.map((o) => [o.id, { token: o.token, fiat_currency: o.fiat_currency, ad_type: o.ad_type }])
);
}
}
// Enrich trades with offer details
const enrichedTrades = (trades || []).map((trade) => ({
...trade,
offer: trade.offer_id ? offersMap[trade.offer_id] || null : null,
}));
return new Response(
JSON.stringify({
success: true,
trades: enrichedTrades,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
@@ -0,0 +1,185 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface GetPaymentMethodsRequest {
sessionToken: string;
currency?: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: GetPaymentMethodsRequest = await req.json();
const { sessionToken, currency } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Build query for payment_methods
let query = supabase
.from('payment_methods')
.select(
'id, currency, country, method_name, method_type, logo_url, fields, min_trade_amount, max_trade_amount, processing_time_minutes'
)
.eq('is_active', true)
.order('display_order', { ascending: true });
// Apply optional currency filter
if (currency) {
query = query.eq('currency', currency);
}
const { data: methods, error: methodsError } = await query;
if (methodsError) {
console.error('Payment methods query error:', methodsError);
return new Response(
JSON.stringify({ error: 'Failed to fetch payment methods: ' + methodsError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
return new Response(
JSON.stringify({
success: true,
methods: methods || [],
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
+385
View File
@@ -0,0 +1,385 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface P2PDisputeRequest {
sessionToken: string;
action: 'open' | 'add_evidence';
tradeId: string;
reason?: string;
category?: 'payment_not_received' | 'wrong_amount' | 'fake_payment_proof' | 'other';
evidenceUrl?: string;
evidenceType?: 'screenshot' | 'receipt' | 'bank_statement' | 'chat_log' | 'transaction_proof' | 'other';
description?: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: P2PDisputeRequest = await req.json();
const { sessionToken, action, tradeId, reason, category, evidenceUrl, evidenceType, description } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate required fields
if (!action || !tradeId) {
return new Response(JSON.stringify({ error: 'Missing required fields: action, tradeId' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!['open', 'add_evidence'].includes(action)) {
return new Response(JSON.stringify({ error: 'Invalid action. Must be "open" or "add_evidence"' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Verify user is a party to this trade
const { data: trade, error: tradeError } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
if (tradeError || !trade) {
return new Response(JSON.stringify({ error: 'Trade not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (trade.seller_id !== userId && trade.buyer_id !== userId) {
return new Response(JSON.stringify({ error: 'You are not a party to this trade' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// ==================== OPEN DISPUTE ====================
if (action === 'open') {
// Trade must be in active status to dispute
if (!['pending', 'payment_sent'].includes(trade.status)) {
return new Response(JSON.stringify({ error: `Cannot open dispute: trade status is '${trade.status}', must be 'pending' or 'payment_sent'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate category
const validCategories = ['payment_not_received', 'wrong_amount', 'fake_payment_proof', 'other'];
const disputeCategory = category || 'other';
if (!validCategories.includes(disputeCategory)) {
return new Response(JSON.stringify({ error: `Invalid category. Must be one of: ${validCategories.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const disputeReason = reason || 'No reason provided';
// Check if there's already an open dispute for this trade
const { data: existingDispute } = await supabase
.from('p2p_fiat_disputes')
.select('id')
.eq('trade_id', tradeId)
.in('status', ['open', 'under_review'])
.single();
if (existingDispute) {
return new Response(JSON.stringify({ error: 'A dispute is already open for this trade' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Insert dispute record
const { data: dispute, error: disputeError } = await supabase
.from('p2p_fiat_disputes')
.insert({
trade_id: tradeId,
opened_by: userId,
reason: disputeReason,
category: disputeCategory,
status: 'open',
})
.select()
.single();
if (disputeError) {
console.error('Create dispute error:', disputeError);
return new Response(
JSON.stringify({ error: 'Failed to create dispute: ' + disputeError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Update trade status to disputed
const now = new Date().toISOString();
const { error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'disputed',
dispute_reason: disputeReason,
dispute_opened_at: now,
dispute_opened_by: userId,
})
.eq('id', tradeId);
if (updateError) {
console.error('Update trade dispute status error:', updateError);
// Dispute was created but trade status update failed - log warning but don't fail
}
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'open_dispute',
entity_type: 'trade',
entity_id: tradeId,
details: {
dispute_id: dispute.id,
reason: disputeReason,
category: disputeCategory,
previous_status: trade.status,
},
});
return new Response(
JSON.stringify({
success: true,
dispute,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// ==================== ADD EVIDENCE ====================
if (action === 'add_evidence') {
// Validate evidence fields
if (!evidenceUrl) {
return new Response(JSON.stringify({ error: 'Missing required field: evidenceUrl' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const validEvidenceTypes = ['screenshot', 'receipt', 'bank_statement', 'chat_log', 'transaction_proof', 'other'];
const evType = evidenceType || 'other';
if (!validEvidenceTypes.includes(evType)) {
return new Response(JSON.stringify({ error: `Invalid evidence type. Must be one of: ${validEvidenceTypes.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Find the dispute for this trade
const { data: dispute, error: disputeError } = await supabase
.from('p2p_fiat_disputes')
.select('id, status')
.eq('trade_id', tradeId)
.in('status', ['open', 'under_review'])
.single();
if (disputeError || !dispute) {
return new Response(JSON.stringify({ error: 'No active dispute found for this trade' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Insert evidence
const { data: evidence, error: evidenceError } = await supabase
.from('p2p_dispute_evidence')
.insert({
dispute_id: dispute.id,
uploaded_by: userId,
evidence_type: evType,
file_url: evidenceUrl,
description: description || null,
})
.select()
.single();
if (evidenceError) {
console.error('Add evidence error:', evidenceError);
return new Response(
JSON.stringify({ error: 'Failed to add evidence: ' + evidenceError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'add_dispute_evidence',
entity_type: 'dispute',
entity_id: dispute.id,
details: {
trade_id: tradeId,
evidence_id: evidence.id,
evidence_type: evType,
},
});
return new Response(
JSON.stringify({
success: true,
dispute: {
id: dispute.id,
status: dispute.status,
evidence,
},
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Should not reach here
return new Response(JSON.stringify({ error: 'Invalid action' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
+278
View File
@@ -0,0 +1,278 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface P2PMessagesRequest {
sessionToken: string;
action: 'send' | 'list';
tradeId: string;
message?: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: P2PMessagesRequest = await req.json();
const { sessionToken, action, tradeId, message } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate required fields
if (!action || !tradeId) {
return new Response(JSON.stringify({ error: 'Missing required fields: action, tradeId' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!['send', 'list'].includes(action)) {
return new Response(JSON.stringify({ error: 'Invalid action. Must be "send" or "list"' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Verify user is a party to this trade
const { data: trade, error: tradeError } = await supabase
.from('p2p_fiat_trades')
.select('seller_id, buyer_id')
.eq('id', tradeId)
.single();
if (tradeError || !trade) {
return new Response(JSON.stringify({ error: 'Trade not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (trade.seller_id !== userId && trade.buyer_id !== userId) {
return new Response(JSON.stringify({ error: 'You are not a party to this trade' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// ==================== SEND ====================
if (action === 'send') {
if (!message || message.trim().length === 0) {
return new Response(JSON.stringify({ error: 'Message cannot be empty' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { data: newMessage, error: insertError } = await supabase
.from('p2p_messages')
.insert({
trade_id: tradeId,
sender_id: userId,
message: message.trim(),
message_type: 'text',
})
.select()
.single();
if (insertError) {
console.error('Send message error:', insertError);
return new Response(
JSON.stringify({ error: 'Failed to send message: ' + insertError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
return new Response(
JSON.stringify({
success: true,
messageId: newMessage.id,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// ==================== LIST ====================
if (action === 'list') {
// Fetch all messages for this trade ordered by created_at ASC
const { data: messages, error: messagesError } = await supabase
.from('p2p_messages')
.select('*')
.eq('trade_id', tradeId)
.order('created_at', { ascending: true });
if (messagesError) {
console.error('List messages error:', messagesError);
return new Response(
JSON.stringify({ error: 'Failed to fetch messages: ' + messagesError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Mark unread messages as read for this user
// (messages sent by the OTHER party that haven't been read yet)
const unreadMessageIds = (messages || [])
.filter((m: { sender_id: string; is_read: boolean; id: string }) => m.sender_id !== userId && !m.is_read)
.map((m: { id: string }) => m.id);
if (unreadMessageIds.length > 0) {
await supabase
.from('p2p_messages')
.update({ is_read: true, read_at: new Date().toISOString() })
.in('id', unreadMessageIds);
}
return new Response(
JSON.stringify({
success: true,
messages: messages || [],
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Should not reach here
return new Response(JSON.stringify({ error: 'Invalid action' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});
+563
View File
@@ -0,0 +1,563 @@
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<string, string> {
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, x-supabase-client-platform',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
}
interface TradeActionRequest {
sessionToken: string;
tradeId: string;
action: 'mark_paid' | 'confirm' | 'cancel' | 'rate';
payload?: {
rating?: number;
review?: string;
reason?: 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) {
// Try legacy format for backwards compatibility
return verifyLegacyToken(token);
}
const [payloadB64, signature] = parts;
// Verify signature
const secret = getSessionSecret(botToken);
const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex');
if (signature !== expectedSig) {
return null;
}
// Parse payload
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (Date.now() > payload.exp) {
return null;
}
return payload.tgId;
} catch {
return null;
}
}
// Legacy token format (Base64 only) - for backwards compatibility
function verifyLegacyToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
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);
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: TradeActionRequest = await req.json();
const { sessionToken, tradeId, action, payload } = body;
// Get bot token for session verification
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken, botToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate required fields
if (!tradeId || !action) {
return new Response(JSON.stringify({ error: 'Missing required fields: tradeId, action' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const validActions = ['mark_paid', 'confirm', 'cancel', 'rate'];
if (!validActions.includes(action)) {
return new Response(JSON.stringify({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// Fetch the trade
const { data: trade, error: tradeError } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
if (tradeError || !trade) {
return new Response(JSON.stringify({ error: 'Trade not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Verify user is a party to this trade
if (trade.seller_id !== userId && trade.buyer_id !== userId) {
return new Response(JSON.stringify({ error: 'You are not a party to this trade' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
let updatedTrade;
// ==================== MARK PAID ====================
if (action === 'mark_paid') {
// Only buyer can mark as paid
if (trade.buyer_id !== userId) {
return new Response(JSON.stringify({ error: 'Only the buyer can mark payment as sent' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Trade must be in pending status
if (trade.status !== 'pending') {
return new Response(JSON.stringify({ error: `Cannot mark paid: trade status is '${trade.status}', expected 'pending'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const now = new Date().toISOString();
const confirmationDeadline = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); // +2 hours
const { data: updated, error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: now,
status: 'payment_sent',
confirmation_deadline: confirmationDeadline,
})
.eq('id', tradeId)
.eq('buyer_id', userId)
.eq('status', 'pending')
.select()
.single();
if (updateError) {
console.error('Mark paid error:', updateError);
return new Response(
JSON.stringify({ error: 'Failed to mark payment: ' + updateError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
updatedTrade = updated;
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'mark_paid',
entity_type: 'trade',
entity_id: tradeId,
details: {
confirmation_deadline: confirmationDeadline,
},
});
}
// ==================== CONFIRM ====================
else if (action === 'confirm') {
// Only seller can confirm
if (trade.seller_id !== userId) {
return new Response(JSON.stringify({ error: 'Only the seller can confirm and release crypto' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Trade must be in payment_sent status
if (trade.status !== 'payment_sent') {
return new Response(JSON.stringify({ error: `Cannot confirm: trade status is '${trade.status}', expected 'payment_sent'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Get offer details to know the token
const { data: offer, error: offerError } = await supabase
.from('p2p_fiat_offers')
.select('token')
.eq('id', trade.offer_id)
.single();
if (offerError || !offer) {
return new Response(JSON.stringify({ error: 'Associated offer not found' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Release escrow: transfer from seller's locked balance to buyer's available balance
const { data: releaseResult, error: releaseError } = await supabase.rpc(
'release_escrow_internal',
{
p_from_user_id: trade.seller_id,
p_to_user_id: trade.buyer_id,
p_token: offer.token,
p_amount: trade.crypto_amount,
p_reference_type: 'trade',
p_reference_id: tradeId,
}
);
if (releaseError) {
console.error('Release escrow error:', releaseError);
return new Response(
JSON.stringify({ error: 'Failed to release escrow: ' + releaseError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
if (releaseResponse && !releaseResponse.success) {
return new Response(
JSON.stringify({ error: releaseResponse.error || 'Failed to release escrow' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Update trade status to completed
const now = new Date().toISOString();
const { data: updated, error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
seller_confirmed_at: now,
escrow_released_at: now,
status: 'completed',
completed_at: now,
})
.eq('id', tradeId)
.select()
.single();
if (updateError) {
console.error('Confirm trade update error:', updateError);
return new Response(
JSON.stringify({ error: 'Escrow released but failed to update trade status: ' + updateError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
updatedTrade = updated;
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'confirm_trade',
entity_type: 'trade',
entity_id: tradeId,
details: {
token: offer.token,
crypto_amount: trade.crypto_amount,
buyer_id: trade.buyer_id,
},
});
}
// ==================== CANCEL ====================
else if (action === 'cancel') {
// Trade must be in pending or payment_sent status to cancel
if (!['pending', 'payment_sent'].includes(trade.status)) {
return new Response(JSON.stringify({ error: `Cannot cancel: trade status is '${trade.status}'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const cancelReason = payload?.reason || 'Cancelled by user';
// Try using the cancel_p2p_trade RPC if it exists
const { data: rpcResult, error: rpcError } = await supabase.rpc('cancel_p2p_trade', {
p_trade_id: tradeId,
p_user_id: userId,
p_reason: cancelReason,
});
if (rpcError) {
// If RPC doesn't exist (42883), do manual cancellation
if (rpcError.code === '42883') {
console.warn('cancel_p2p_trade RPC not found, performing manual cancellation');
// Update trade status
const { data: updated, error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'cancelled',
cancelled_by: userId,
cancellation_reason: cancelReason,
})
.eq('id', tradeId)
.select()
.single();
if (updateError) {
console.error('Cancel trade error:', updateError);
return new Response(
JSON.stringify({ error: 'Failed to cancel trade: ' + updateError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Restore offer remaining amount
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.select('id, remaining_amount')
.eq('id', trade.offer_id)
.single();
if (offer) {
await supabase
.from('p2p_fiat_offers')
.update({
remaining_amount: (offer.remaining_amount || 0) + (trade.crypto_amount || 0),
status: 'open',
})
.eq('id', offer.id);
}
updatedTrade = updated;
} else {
console.error('Cancel trade RPC error:', rpcError);
return new Response(
JSON.stringify({ error: 'Failed to cancel trade: ' + rpcError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
} else {
// RPC succeeded, parse result
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
if (result && !result.success) {
return new Response(
JSON.stringify({ error: result.error || 'Failed to cancel trade' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Fetch updated trade
const { data: updated } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
updatedTrade = updated;
}
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'cancel_trade',
entity_type: 'trade',
entity_id: tradeId,
details: {
reason: cancelReason,
previous_status: trade.status,
},
});
}
// ==================== RATE ====================
else if (action === 'rate') {
// Trade must be completed to rate
if (trade.status !== 'completed') {
return new Response(JSON.stringify({ error: 'Can only rate completed trades' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate rating payload
if (!payload?.rating || payload.rating < 1 || payload.rating > 5) {
return new Response(JSON.stringify({ error: 'Rating must be between 1 and 5' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Determine counterparty
const ratedId = trade.seller_id === userId ? trade.buyer_id : trade.seller_id;
// Check if user already rated this trade
const { data: existingRating } = await supabase
.from('p2p_ratings')
.select('id')
.eq('trade_id', tradeId)
.eq('rater_id', userId)
.single();
if (existingRating) {
return new Response(JSON.stringify({ error: 'You have already rated this trade' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Insert rating
const { data: rating, error: ratingError } = await supabase
.from('p2p_ratings')
.insert({
trade_id: tradeId,
rater_id: userId,
rated_id: ratedId,
rating: payload.rating,
review: payload.review || null,
})
.select()
.single();
if (ratingError) {
console.error('Insert rating error:', ratingError);
return new Response(
JSON.stringify({ error: 'Failed to submit rating: ' + ratingError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Fetch trade as-is for response
updatedTrade = trade;
// Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'rate_trade',
entity_type: 'trade',
entity_id: tradeId,
details: {
rated_id: ratedId,
rating: payload.rating,
review: payload.review || null,
},
});
}
return new Response(
JSON.stringify({
success: true,
trade: updatedTrade,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
const origin = req.headers.get('origin');
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' },
}
);
}
});