mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-22 03:07:55 +00:00
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:
+11
-49
@@ -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 { Megaphone, MessageCircle, Gift, Wallet, Loader2, ArrowLeftRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||||
import { P2PModal } from '@/components/P2PModal';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { useWallet } from '@/contexts/WalletContext';
|
|
||||||
import { useTranslation } from '@/i18n';
|
import { useTranslation } from '@/i18n';
|
||||||
|
|
||||||
// Lazy load sections for code splitting
|
// Lazy load sections for code splitting
|
||||||
@@ -20,6 +17,9 @@ const RewardsSection = lazy(() =>
|
|||||||
const WalletSection = lazy(() =>
|
const WalletSection = lazy(() =>
|
||||||
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
|
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
|
||||||
);
|
);
|
||||||
|
const P2PSection = lazy(() =>
|
||||||
|
import('@/sections/P2P').then((m) => ({ default: m.P2PSection }))
|
||||||
|
);
|
||||||
const CitizenPage = lazy(() =>
|
const CitizenPage = lazy(() =>
|
||||||
import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage }))
|
import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage }))
|
||||||
);
|
);
|
||||||
@@ -36,27 +36,22 @@ function SectionLoader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Section = 'announcements' | 'forum' | 'rewards' | 'wallet';
|
type Section = 'announcements' | 'forum' | 'rewards' | 'p2p' | 'wallet';
|
||||||
type NavId = Section | 'p2p';
|
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: NavId;
|
id: Section;
|
||||||
icon: typeof Megaphone;
|
icon: typeof Megaphone;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
isExternal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ id: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
{ id: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||||
{ id: 'forum', icon: MessageCircle, labelKey: 'nav.forum' },
|
{ id: 'forum', icon: MessageCircle, labelKey: 'nav.forum' },
|
||||||
{ id: 'rewards', icon: Gift, labelKey: 'nav.rewards' },
|
{ 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' },
|
{ 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)
|
// 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 PAGE_PARAM = new URLSearchParams(window.location.search).get('page');
|
||||||
const IS_CITIZEN_PAGE = PAGE_PARAM === 'citizen' || window.location.pathname === '/citizens';
|
const IS_CITIZEN_PAGE = PAGE_PARAM === 'citizen' || window.location.pathname === '/citizens';
|
||||||
@@ -84,42 +79,11 @@ export default function App() {
|
|||||||
|
|
||||||
function MainApp() {
|
function MainApp() {
|
||||||
const [activeSection, setActiveSection] = useState<Section>('announcements');
|
const [activeSection, setActiveSection] = useState<Section>('announcements');
|
||||||
const [showP2PModal, setShowP2PModal] = useState(false);
|
|
||||||
const { sessionToken } = useAuth();
|
|
||||||
const { address } = useWallet();
|
|
||||||
const { t } = useTranslation();
|
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) => {
|
const handleNavClick = (item: NavItem) => {
|
||||||
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
|
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
|
||||||
|
setActiveSection(item.id);
|
||||||
if (item.isExternal) {
|
|
||||||
// P2P opens modal first
|
|
||||||
if (item.id === 'p2p') {
|
|
||||||
setShowP2PModal(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setActiveSection(item.id as Section);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,6 +95,7 @@ function MainApp() {
|
|||||||
{activeSection === 'announcements' && <AnnouncementsSection />}
|
{activeSection === 'announcements' && <AnnouncementsSection />}
|
||||||
{activeSection === 'forum' && <ForumSection />}
|
{activeSection === 'forum' && <ForumSection />}
|
||||||
{activeSection === 'rewards' && <RewardsSection />}
|
{activeSection === 'rewards' && <RewardsSection />}
|
||||||
|
{activeSection === 'p2p' && <P2PSection />}
|
||||||
{activeSection === 'wallet' && <WalletSection />}
|
{activeSection === 'wallet' && <WalletSection />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,15 +104,12 @@ function MainApp() {
|
|||||||
{/* Update Notification */}
|
{/* Update Notification */}
|
||||||
<UpdateNotification />
|
<UpdateNotification />
|
||||||
|
|
||||||
{/* P2P Modal */}
|
|
||||||
<P2PModal isOpen={showP2PModal} onClose={() => setShowP2PModal(false)} onOpenP2P={openP2P} />
|
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
{/* Bottom Navigation */}
|
||||||
<nav className="flex-shrink-0 bg-secondary/50 backdrop-blur-lg border-t border-border safe-area-bottom">
|
<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">
|
<div className="flex justify-around items-center h-16">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = !item.isExternal && activeSection === item.id;
|
const isActive = activeSection === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -156,7 +118,7 @@ function MainApp() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
|
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
|
||||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
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')} />
|
<Icon className={cn('w-5 h-5', isActive && 'scale-110')} />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -304,16 +304,98 @@ const ar: Translations = {
|
|||||||
|
|
||||||
p2p: {
|
p2p: {
|
||||||
title: 'تبادل P2P',
|
title: 'تبادل P2P',
|
||||||
subtitle: 'تداول العملات الرقمية بين الأفراد',
|
subtitle: 'تداول العملات المشفرة بين الأفراد',
|
||||||
firstTime: 'أول مرة تستخدم P2P؟',
|
firstTime: 'أول مرة تستخدم P2P؟',
|
||||||
steps: [
|
steps: [
|
||||||
'انقر على الزر أدناه لفتح تطبيق الويب',
|
'اضغط الزر أدناه لفتح تطبيق الويب',
|
||||||
'أنشئ حسابًا أو سجّل الدخول',
|
'أنشئ حساب أو سجل الدخول',
|
||||||
'أكمل عملية إعداد P2P',
|
'أكمل عملية الإعداد',
|
||||||
'بعد الإعداد، يمكنك الوصول إلى P2P مباشرة',
|
'بعد الإعداد، يمكنك الوصول مباشرة إلى P2P',
|
||||||
],
|
],
|
||||||
note: 'سيُفتح تطبيق الويب في نافذة جديدة. أكمل عملية التسجيل هناك.',
|
note: 'سيفتح تطبيق الويب في نافذة جديدة.',
|
||||||
button: 'فتح منصة P2P',
|
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: {
|
update: {
|
||||||
|
|||||||
@@ -306,16 +306,98 @@ const ckb: Translations = {
|
|||||||
|
|
||||||
p2p: {
|
p2p: {
|
||||||
title: 'ئاڵوگۆڕی P2P',
|
title: 'ئاڵوگۆڕی P2P',
|
||||||
subtitle: 'ئاڵوگۆڕی کریپتۆ نێوان کەسەکان',
|
subtitle: 'بازرگانیی کریپتۆ لە نێوان کەسەکاندا',
|
||||||
firstTime: 'یەکەم جارە P2P بەکاردەهێنیت؟',
|
firstTime: 'یەکەم جارە P2P بەکاردەهێنیت؟',
|
||||||
steps: [
|
steps: [
|
||||||
'کلیک لەسەر دوگمەی خوارەوە بکە بۆ کردنەوەی وێب ئەپ',
|
'دوگمەی خوارەوە دابگرە بۆ کردنەوەی وێب ئەپ',
|
||||||
'ئەکاونتێک دروستبکە یان بچۆ ژوورەوە',
|
'هەژمارێک دروست بکە یان بچۆ ژوورەوە',
|
||||||
'ڕێکخستنی P2P تەواو بکە',
|
'پرۆسەی دامەزراندن تەواو بکە',
|
||||||
'دوای ڕێکخستن، دەتوانیت ڕاستەوخۆ دەستت بە P2P بگات',
|
'دوای دامەزراندن، دەتوانیت ڕاستەوخۆ دەستت بگات بە P2P',
|
||||||
],
|
],
|
||||||
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە. ڕێکخستنەکە لەوێ تەواو بکە.',
|
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە.',
|
||||||
button: 'کردنەوەی پلاتفۆرمی P2P',
|
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: {
|
update: {
|
||||||
|
|||||||
@@ -315,6 +315,88 @@ const en: Translations = {
|
|||||||
],
|
],
|
||||||
note: 'The web app will open in a new window. Complete the registration process there.',
|
note: 'The web app will open in a new window. Complete the registration process there.',
|
||||||
button: 'Open P2P Platform',
|
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: {
|
update: {
|
||||||
|
|||||||
@@ -305,16 +305,98 @@ const fa: Translations = {
|
|||||||
|
|
||||||
p2p: {
|
p2p: {
|
||||||
title: 'صرافی P2P',
|
title: 'صرافی P2P',
|
||||||
subtitle: 'معامله رمزارز به صورت همتا به همتا',
|
subtitle: 'معامله کریپتو نفر به نفر',
|
||||||
firstTime: 'اولین بار است که از P2P استفاده میکنید؟',
|
firstTime: 'اولین بار از P2P استفاده میکنید؟',
|
||||||
steps: [
|
steps: [
|
||||||
'روی دکمه زیر کلیک کنید تا برنامه وب باز شود',
|
'دکمه زیر را برای باز کردن وب اپلیکیشن بزنید',
|
||||||
'یک حساب بسازید یا وارد شوید',
|
'حساب بسازید یا وارد شوید',
|
||||||
'فرآیند راهاندازی P2P را تکمیل کنید',
|
'فرایند راهاندازی را کامل کنید',
|
||||||
'پس از راهاندازی، میتوانید مستقیماً به P2P دسترسی پیدا کنید',
|
'بعد از راهاندازی، مستقیم به P2P دسترسی دارید',
|
||||||
],
|
],
|
||||||
note: 'برنامه وب در پنجره جدیدی باز میشود. فرآیند ثبتنام را در آنجا تکمیل کنید.',
|
note: 'وب اپلیکیشن در پنجره جدید باز میشود.',
|
||||||
button: 'باز کردن پلتفرم P2P',
|
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: {
|
update: {
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ const krd: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
p2p: {
|
p2p: {
|
||||||
title: 'P2P Dan\u00fbstandin',
|
title: 'Danûstandina P2P',
|
||||||
subtitle: 'Dan\u00fbstandina kr\u00eeto di navbera kesan de',
|
subtitle: 'Dan\u00fbstandina kr\u00eeto di navbera kesan de',
|
||||||
firstTime: 'Cara yekem P2P bikar t\u00eenin?',
|
firstTime: 'Cara yekem P2P bikar t\u00eenin?',
|
||||||
steps: [
|
steps: [
|
||||||
@@ -326,7 +326,89 @@ const krd: Translations = {
|
|||||||
'Pi\u015ft\u00ee sazkirin\u00ea, h\u00fbn dikarin rasterast bigih\u00eejin P2P',
|
'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.',
|
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: {
|
update: {
|
||||||
|
|||||||
@@ -305,16 +305,98 @@ const tr: Translations = {
|
|||||||
|
|
||||||
p2p: {
|
p2p: {
|
||||||
title: 'P2P Borsa',
|
title: 'P2P Borsa',
|
||||||
subtitle: 'Eşler arası kripto ticareti',
|
subtitle: 'Eşler arası kripto alım-satım',
|
||||||
firstTime: "P2P'yi ilk kez mi kullanıyorsunuz?",
|
firstTime: 'P2P\'yi ilk kez mi kullanıyorsun?',
|
||||||
steps: [
|
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ı açın',
|
||||||
'Hesap oluşturun veya giriş yapın',
|
'Hesap oluşturun veya giriş yapın',
|
||||||
'P2P kurulum sürecini tamamlayın',
|
'Kurulum sürecini tamamlayın',
|
||||||
"Kurulumdan sonra P2P'ye doğrudan erişebilirsiniz",
|
'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ç',
|
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: {
|
update: {
|
||||||
|
|||||||
+93
-1
@@ -308,7 +308,7 @@ export interface Translations {
|
|||||||
trc20FeeWarning: string;
|
trc20FeeWarning: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// P2P Modal
|
// P2P Section
|
||||||
p2p: {
|
p2p: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -316,6 +316,98 @@ export interface Translations {
|
|||||||
steps: string[];
|
steps: string[];
|
||||||
note: string;
|
note: string;
|
||||||
button: 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
|
// Update Notification
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user