mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-21 23:37: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 { cn } from '@/lib/utils';
|
||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||
import { P2PModal } from '@/components/P2PModal';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTranslation } from '@/i18n';
|
||||
|
||||
// Lazy load sections for code splitting
|
||||
@@ -20,6 +17,9 @@ const RewardsSection = lazy(() =>
|
||||
const WalletSection = lazy(() =>
|
||||
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
|
||||
);
|
||||
const P2PSection = lazy(() =>
|
||||
import('@/sections/P2P').then((m) => ({ default: m.P2PSection }))
|
||||
);
|
||||
const CitizenPage = lazy(() =>
|
||||
import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage }))
|
||||
);
|
||||
@@ -36,27 +36,22 @@ function SectionLoader() {
|
||||
);
|
||||
}
|
||||
|
||||
type Section = 'announcements' | 'forum' | 'rewards' | 'wallet';
|
||||
type NavId = Section | 'p2p';
|
||||
type Section = 'announcements' | 'forum' | 'rewards' | 'p2p' | 'wallet';
|
||||
|
||||
interface NavItem {
|
||||
id: NavId;
|
||||
id: Section;
|
||||
icon: typeof Megaphone;
|
||||
labelKey: string;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
{ id: 'forum', icon: MessageCircle, labelKey: 'nav.forum' },
|
||||
{ id: 'rewards', icon: Gift, labelKey: 'nav.rewards' },
|
||||
{ id: 'p2p', icon: ArrowLeftRight, labelKey: 'nav.p2p', isExternal: true },
|
||||
{ id: 'p2p', icon: ArrowLeftRight, labelKey: 'nav.p2p' },
|
||||
{ id: 'wallet', icon: Wallet, labelKey: 'nav.wallet' },
|
||||
];
|
||||
|
||||
// P2P Web App URL - Mobile-optimized P2P
|
||||
const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p';
|
||||
|
||||
// Check for standalone pages via URL query params or path (evaluated once at module level)
|
||||
const PAGE_PARAM = new URLSearchParams(window.location.search).get('page');
|
||||
const IS_CITIZEN_PAGE = PAGE_PARAM === 'citizen' || window.location.pathname === '/citizens';
|
||||
@@ -84,42 +79,11 @@ export default function App() {
|
||||
|
||||
function MainApp() {
|
||||
const [activeSection, setActiveSection] = useState<Section>('announcements');
|
||||
const [showP2PModal, setShowP2PModal] = useState(false);
|
||||
const { sessionToken } = useAuth();
|
||||
const { address } = useWallet();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Open P2P in popup with auth params
|
||||
const openP2P = useCallback(() => {
|
||||
window.Telegram?.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
|
||||
// Build auth URL with session token
|
||||
const params = new URLSearchParams();
|
||||
if (sessionToken) {
|
||||
params.set('session_token', sessionToken);
|
||||
}
|
||||
if (address) {
|
||||
params.set('wallet', address);
|
||||
}
|
||||
params.set('from', 'miniapp');
|
||||
|
||||
const url = `${P2P_WEB_URL}?${params.toString()}`;
|
||||
|
||||
// Open in new window/tab
|
||||
window.open(url, '_blank');
|
||||
}, [sessionToken, address]);
|
||||
|
||||
const handleNavClick = (item: NavItem) => {
|
||||
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
|
||||
|
||||
if (item.isExternal) {
|
||||
// P2P opens modal first
|
||||
if (item.id === 'p2p') {
|
||||
setShowP2PModal(true);
|
||||
}
|
||||
} else {
|
||||
setActiveSection(item.id as Section);
|
||||
}
|
||||
setActiveSection(item.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -131,6 +95,7 @@ function MainApp() {
|
||||
{activeSection === 'announcements' && <AnnouncementsSection />}
|
||||
{activeSection === 'forum' && <ForumSection />}
|
||||
{activeSection === 'rewards' && <RewardsSection />}
|
||||
{activeSection === 'p2p' && <P2PSection />}
|
||||
{activeSection === 'wallet' && <WalletSection />}
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -139,15 +104,12 @@ function MainApp() {
|
||||
{/* Update Notification */}
|
||||
<UpdateNotification />
|
||||
|
||||
{/* P2P Modal */}
|
||||
<P2PModal isOpen={showP2PModal} onClose={() => setShowP2PModal(false)} onOpenP2P={openP2P} />
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<nav className="flex-shrink-0 bg-secondary/50 backdrop-blur-lg border-t border-border safe-area-bottom">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = !item.isExternal && activeSection === item.id;
|
||||
const isActive = activeSection === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -156,7 +118,7 @@ function MainApp() {
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
item.isExternal && 'text-cyan-400 hover:text-cyan-300'
|
||||
item.id === 'p2p' && !isActive && 'text-cyan-400 hover:text-cyan-300'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-5 h-5', isActive && 'scale-110')} />
|
||||
|
||||
@@ -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: {
|
||||
title: 'تبادل P2P',
|
||||
subtitle: 'تداول العملات الرقمية بين الأفراد',
|
||||
subtitle: 'تداول العملات المشفرة بين الأفراد',
|
||||
firstTime: 'أول مرة تستخدم P2P؟',
|
||||
steps: [
|
||||
'انقر على الزر أدناه لفتح تطبيق الويب',
|
||||
'أنشئ حسابًا أو سجّل الدخول',
|
||||
'أكمل عملية إعداد P2P',
|
||||
'بعد الإعداد، يمكنك الوصول إلى P2P مباشرة',
|
||||
'اضغط الزر أدناه لفتح تطبيق الويب',
|
||||
'أنشئ حساب أو سجل الدخول',
|
||||
'أكمل عملية الإعداد',
|
||||
'بعد الإعداد، يمكنك الوصول مباشرة إلى P2P',
|
||||
],
|
||||
note: 'سيُفتح تطبيق الويب في نافذة جديدة. أكمل عملية التسجيل هناك.',
|
||||
note: 'سيفتح تطبيق الويب في نافذة جديدة.',
|
||||
button: 'فتح منصة P2P',
|
||||
buy: 'شراء',
|
||||
sell: 'بيع',
|
||||
myAds: 'إعلاناتي',
|
||||
myTrades: 'صفقاتي',
|
||||
internalBalance: 'رصيد P2P',
|
||||
available: 'متاح',
|
||||
locked: 'مقفل',
|
||||
totalBalance: 'المجموع',
|
||||
noBalance: 'لا يوجد رصيد بعد. قم بالإيداع لبدء التداول.',
|
||||
createOffer: 'إنشاء عرض',
|
||||
acceptOffer: 'قبول العرض',
|
||||
confirmAccept: 'تأكيد وقبول',
|
||||
noOffers: 'لا توجد عروض متاحة',
|
||||
filters: 'الفلاتر',
|
||||
allTokens: 'جميع التوكنات',
|
||||
allCurrencies: 'جميع العملات',
|
||||
prev: 'السابق',
|
||||
next: 'التالي',
|
||||
min: 'الحد الأدنى',
|
||||
max: 'الحد الأقصى',
|
||||
trades: 'صفقات',
|
||||
token: 'توكن',
|
||||
fiatCurrency: 'العملة',
|
||||
pricePerUnit: 'سعر الوحدة',
|
||||
amount: 'المبلغ',
|
||||
fiatTotal: 'إجمالي الفيات',
|
||||
paymentMethod: 'طريقة الدفع',
|
||||
selectPaymentMethod: 'اختر طريقة الدفع',
|
||||
paymentDetails: 'تفاصيل الدفع',
|
||||
paymentDetailsPlaceholder: 'أدخل تفاصيل الدفع (حساب بنكي، إلخ)',
|
||||
minOrder: 'الحد الأدنى للطلب',
|
||||
maxOrder: 'الحد الأقصى للطلب',
|
||||
timeLimit: 'المهلة الزمنية',
|
||||
minutes: 'دقيقة',
|
||||
insufficientBalance: 'رصيد غير كافي',
|
||||
timeLimitWarning: 'لديك {minutes} دقيقة لإتمام الدفع بعد القبول.',
|
||||
tradeDetails: 'تفاصيل الصفقة',
|
||||
tradeNotFound: 'الصفقة غير موجودة',
|
||||
role: 'الدور',
|
||||
buyer: 'المشتري',
|
||||
seller: 'البائع',
|
||||
cryptoAmount: 'مبلغ العملة المشفرة',
|
||||
fiatAmount: 'مبلغ الفيات',
|
||||
timeline: 'الجدول الزمني',
|
||||
tradeCreated: 'تم إنشاء الصفقة',
|
||||
paymentSent: 'تم إرسال الدفع',
|
||||
paymentConfirmed: 'تم تأكيد الدفع',
|
||||
tradeCompleted: 'تمت الصفقة',
|
||||
timeRemaining: 'الوقت المتبقي',
|
||||
expired: 'منتهية الصلاحية',
|
||||
markPaid: 'قمت بالدفع',
|
||||
confirmReceived: 'تأكيد استلام الدفع',
|
||||
cancelTrade: 'إلغاء الصفقة',
|
||||
status: {
|
||||
open: 'مفتوح',
|
||||
paused: 'متوقف',
|
||||
locked: 'مقفل',
|
||||
completed: 'مكتمل',
|
||||
cancelled: 'ملغي',
|
||||
pending: 'معلق',
|
||||
payment_sent: 'تم إرسال الدفع',
|
||||
disputed: 'متنازع عليه',
|
||||
refunded: 'مسترد',
|
||||
},
|
||||
chat: 'الدردشة',
|
||||
noMessages: 'لا توجد رسائل بعد',
|
||||
messagePlaceholder: 'اكتب رسالة...',
|
||||
openDispute: 'فتح نزاع',
|
||||
disputeWarning: 'فتح نزاع سيجمد الصفقة. سيقوم مشرف بمراجعتها.',
|
||||
disputeCategory: 'الفئة',
|
||||
selectCategory: 'اختر الفئة',
|
||||
disputeReason: 'السبب',
|
||||
disputeReasonPlaceholder: 'صف المشكلة بالتفصيل (١٠ أحرف على الأقل)',
|
||||
minChars: 'على الأقل {count} أحرف',
|
||||
submitDispute: 'إرسال النزاع',
|
||||
dispute: {
|
||||
payment_not_received: 'لم يتم استلام الدفع',
|
||||
wrong_amount: 'مبلغ خاطئ',
|
||||
fake_payment_proof: 'إثبات دفع مزور',
|
||||
other: 'أخرى',
|
||||
},
|
||||
noTrades: 'لا توجد صفقات بعد',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
@@ -306,16 +306,98 @@ const ckb: Translations = {
|
||||
|
||||
p2p: {
|
||||
title: 'ئاڵوگۆڕی P2P',
|
||||
subtitle: 'ئاڵوگۆڕی کریپتۆ نێوان کەسەکان',
|
||||
subtitle: 'بازرگانیی کریپتۆ لە نێوان کەسەکاندا',
|
||||
firstTime: 'یەکەم جارە P2P بەکاردەهێنیت؟',
|
||||
steps: [
|
||||
'کلیک لەسەر دوگمەی خوارەوە بکە بۆ کردنەوەی وێب ئەپ',
|
||||
'ئەکاونتێک دروستبکە یان بچۆ ژوورەوە',
|
||||
'ڕێکخستنی P2P تەواو بکە',
|
||||
'دوای ڕێکخستن، دەتوانیت ڕاستەوخۆ دەستت بە P2P بگات',
|
||||
'دوگمەی خوارەوە دابگرە بۆ کردنەوەی وێب ئەپ',
|
||||
'هەژمارێک دروست بکە یان بچۆ ژوورەوە',
|
||||
'پرۆسەی دامەزراندن تەواو بکە',
|
||||
'دوای دامەزراندن، دەتوانیت ڕاستەوخۆ دەستت بگات بە P2P',
|
||||
],
|
||||
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە. ڕێکخستنەکە لەوێ تەواو بکە.',
|
||||
button: 'کردنەوەی پلاتفۆرمی P2P',
|
||||
note: 'وێب ئەپ لە پەنجەرەیەکی نوێدا دەکرێتەوە.',
|
||||
button: 'پلاتفۆرمی P2P بکەرەوە',
|
||||
buy: 'بکڕە',
|
||||
sell: 'بفرۆشە',
|
||||
myAds: 'ڕیکلامەکانم',
|
||||
myTrades: 'بازرگانییەکانم',
|
||||
internalBalance: 'باڵانسی P2P',
|
||||
available: 'بەردەست',
|
||||
locked: 'قفڵکراو',
|
||||
totalBalance: 'کۆی گشتی',
|
||||
noBalance: 'هێشتا باڵانس نییە. بۆ دەستپێکردن پارە دابنێ.',
|
||||
createOffer: 'پێشنیار دروست بکە',
|
||||
acceptOffer: 'پێشنیار قبوڵ بکە',
|
||||
confirmAccept: 'دڵنیابکەرەوە و قبوڵ بکە',
|
||||
noOffers: 'هیچ پێشنیارێک نییە',
|
||||
filters: 'فلتەرەکان',
|
||||
allTokens: 'هەموو تۆکنەکان',
|
||||
allCurrencies: 'هەموو دراوەکان',
|
||||
prev: 'پێشوو',
|
||||
next: 'دواتر',
|
||||
min: 'کەمترین',
|
||||
max: 'زۆرترین',
|
||||
trades: 'بازرگانی',
|
||||
token: 'تۆکن',
|
||||
fiatCurrency: 'دراو',
|
||||
pricePerUnit: 'نرخی یەکە',
|
||||
amount: 'بڕ',
|
||||
fiatTotal: 'کۆی فیات',
|
||||
paymentMethod: 'شێوازی پارەدان',
|
||||
selectPaymentMethod: 'شێوازی پارەدان هەڵبژێرە',
|
||||
paymentDetails: 'وردەکاری پارەدان',
|
||||
paymentDetailsPlaceholder: 'وردەکاری پارەدانت بنووسە (هەژماری بانکی، هتد)',
|
||||
minOrder: 'کەمترین داواکاری',
|
||||
maxOrder: 'زۆرترین داواکاری',
|
||||
timeLimit: 'سنووری کات',
|
||||
minutes: 'خولەک',
|
||||
insufficientBalance: 'باڵانس بەس نییە',
|
||||
timeLimitWarning: 'دوای قبوڵکردن {minutes} خولەکت هەیە بۆ تەواوکردنی پارەدان.',
|
||||
tradeDetails: 'وردەکاری بازرگانی',
|
||||
tradeNotFound: 'بازرگانی نەدۆزرایەوە',
|
||||
role: 'ڕۆڵ',
|
||||
buyer: 'کڕیار',
|
||||
seller: 'فرۆشیار',
|
||||
cryptoAmount: 'بڕی کریپتۆ',
|
||||
fiatAmount: 'بڕی فیات',
|
||||
timeline: 'هێڵی کات',
|
||||
tradeCreated: 'بازرگانی دروستکرا',
|
||||
paymentSent: 'پارەدان نێردرا',
|
||||
paymentConfirmed: 'پارەدان پشتڕاستکرایەوە',
|
||||
tradeCompleted: 'بازرگانی تەواوبوو',
|
||||
timeRemaining: 'کاتی ماوە',
|
||||
expired: 'بەسەرچوو',
|
||||
markPaid: 'پارەم دا',
|
||||
confirmReceived: 'وەرگرتنی پارە پشتڕاست بکەرەوە',
|
||||
cancelTrade: 'بازرگانی هەڵبوەشێنەرەوە',
|
||||
status: {
|
||||
open: 'کراوە',
|
||||
paused: 'وەستێنراو',
|
||||
locked: 'قفڵکراو',
|
||||
completed: 'تەواوبوو',
|
||||
cancelled: 'هەڵوەشێنراوە',
|
||||
pending: 'چاوەڕوانی',
|
||||
payment_sent: 'پارەدان نێردرا',
|
||||
disputed: 'ناکۆکی',
|
||||
refunded: 'گەڕێنراوە',
|
||||
},
|
||||
chat: 'چات',
|
||||
noMessages: 'هێشتا پەیام نییە',
|
||||
messagePlaceholder: 'پەیامێک بنووسە...',
|
||||
openDispute: 'ناکۆکی بکەرەوە',
|
||||
disputeWarning: 'کردنەوەی ناکۆکی بازرگانی دەسەڕینێت. مۆدەراتۆرێک پشکنینی دەکات.',
|
||||
disputeCategory: 'پۆل',
|
||||
selectCategory: 'پۆلێک هەڵبژێرە',
|
||||
disputeReason: 'هۆکار',
|
||||
disputeReasonPlaceholder: 'کێشەکە بە وردی باسبکە (لانیکەم ١٠ پیت)',
|
||||
minChars: 'لانیکەم {count} پیت',
|
||||
submitDispute: 'ناکۆکی بنێرە',
|
||||
dispute: {
|
||||
payment_not_received: 'پارەدان وەرنەگیرا',
|
||||
wrong_amount: 'بڕی هەڵە',
|
||||
fake_payment_proof: 'بەڵگەی پارەدانی ساختە',
|
||||
other: 'هی تر',
|
||||
},
|
||||
noTrades: 'هێشتا بازرگانی نییە',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
@@ -315,6 +315,88 @@ const en: Translations = {
|
||||
],
|
||||
note: 'The web app will open in a new window. Complete the registration process there.',
|
||||
button: 'Open P2P Platform',
|
||||
buy: 'Buy',
|
||||
sell: 'Sell',
|
||||
myAds: 'My Ads',
|
||||
myTrades: 'My Trades',
|
||||
internalBalance: 'P2P Balance',
|
||||
available: 'Available',
|
||||
locked: 'Locked',
|
||||
totalBalance: 'Total',
|
||||
noBalance: 'No balance yet. Deposit to start trading.',
|
||||
createOffer: 'Create Offer',
|
||||
acceptOffer: 'Accept Offer',
|
||||
confirmAccept: 'Confirm & Accept',
|
||||
noOffers: 'No offers available',
|
||||
filters: 'Filters',
|
||||
allTokens: 'All Tokens',
|
||||
allCurrencies: 'All Currencies',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
trades: 'trades',
|
||||
token: 'Token',
|
||||
fiatCurrency: 'Currency',
|
||||
pricePerUnit: 'Price per unit',
|
||||
amount: 'Amount',
|
||||
fiatTotal: 'Fiat Total',
|
||||
paymentMethod: 'Payment Method',
|
||||
selectPaymentMethod: 'Select payment method',
|
||||
paymentDetails: 'Payment Details',
|
||||
paymentDetailsPlaceholder: 'Enter your payment details (bank account, etc.)',
|
||||
minOrder: 'Min Order',
|
||||
maxOrder: 'Max Order',
|
||||
timeLimit: 'Time Limit',
|
||||
minutes: 'min',
|
||||
insufficientBalance: 'Insufficient balance',
|
||||
timeLimitWarning: 'You have {minutes} minutes to complete the payment after accepting.',
|
||||
tradeDetails: 'Trade Details',
|
||||
tradeNotFound: 'Trade not found',
|
||||
role: 'Role',
|
||||
buyer: 'Buyer',
|
||||
seller: 'Seller',
|
||||
cryptoAmount: 'Crypto Amount',
|
||||
fiatAmount: 'Fiat Amount',
|
||||
timeline: 'Timeline',
|
||||
tradeCreated: 'Trade Created',
|
||||
paymentSent: 'Payment Sent',
|
||||
paymentConfirmed: 'Payment Confirmed',
|
||||
tradeCompleted: 'Trade Completed',
|
||||
timeRemaining: 'Time remaining',
|
||||
expired: 'Expired',
|
||||
markPaid: 'I Have Paid',
|
||||
confirmReceived: 'Confirm Payment Received',
|
||||
cancelTrade: 'Cancel Trade',
|
||||
status: {
|
||||
open: 'Open',
|
||||
paused: 'Paused',
|
||||
locked: 'Locked',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
pending: 'Pending',
|
||||
payment_sent: 'Payment Sent',
|
||||
disputed: 'Disputed',
|
||||
refunded: 'Refunded',
|
||||
},
|
||||
chat: 'Chat',
|
||||
noMessages: 'No messages yet',
|
||||
messagePlaceholder: 'Type a message...',
|
||||
openDispute: 'Open Dispute',
|
||||
disputeWarning: 'Opening a dispute will freeze the trade. A moderator will review the case.',
|
||||
disputeCategory: 'Category',
|
||||
selectCategory: 'Select category',
|
||||
disputeReason: 'Reason',
|
||||
disputeReasonPlaceholder: 'Describe the issue in detail (min 10 characters)',
|
||||
minChars: 'Min {count} characters',
|
||||
submitDispute: 'Submit Dispute',
|
||||
dispute: {
|
||||
payment_not_received: 'Payment not received',
|
||||
wrong_amount: 'Wrong amount',
|
||||
fake_payment_proof: 'Fake payment proof',
|
||||
other: 'Other',
|
||||
},
|
||||
noTrades: 'No trades yet',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
@@ -305,16 +305,98 @@ const fa: Translations = {
|
||||
|
||||
p2p: {
|
||||
title: 'صرافی P2P',
|
||||
subtitle: 'معامله رمزارز به صورت همتا به همتا',
|
||||
firstTime: 'اولین بار است که از P2P استفاده میکنید؟',
|
||||
subtitle: 'معامله کریپتو نفر به نفر',
|
||||
firstTime: 'اولین بار از P2P استفاده میکنید؟',
|
||||
steps: [
|
||||
'روی دکمه زیر کلیک کنید تا برنامه وب باز شود',
|
||||
'یک حساب بسازید یا وارد شوید',
|
||||
'فرآیند راهاندازی P2P را تکمیل کنید',
|
||||
'پس از راهاندازی، میتوانید مستقیماً به P2P دسترسی پیدا کنید',
|
||||
'دکمه زیر را برای باز کردن وب اپلیکیشن بزنید',
|
||||
'حساب بسازید یا وارد شوید',
|
||||
'فرایند راهاندازی را کامل کنید',
|
||||
'بعد از راهاندازی، مستقیم به P2P دسترسی دارید',
|
||||
],
|
||||
note: 'برنامه وب در پنجره جدیدی باز میشود. فرآیند ثبتنام را در آنجا تکمیل کنید.',
|
||||
note: 'وب اپلیکیشن در پنجره جدید باز میشود.',
|
||||
button: 'باز کردن پلتفرم P2P',
|
||||
buy: 'خرید',
|
||||
sell: 'فروش',
|
||||
myAds: 'آگهیهای من',
|
||||
myTrades: 'معاملات من',
|
||||
internalBalance: 'موجودی P2P',
|
||||
available: 'قابل استفاده',
|
||||
locked: 'قفل شده',
|
||||
totalBalance: 'مجموع',
|
||||
noBalance: 'هنوز موجودی ندارید. برای شروع واریز کنید.',
|
||||
createOffer: 'ایجاد پیشنهاد',
|
||||
acceptOffer: 'قبول پیشنهاد',
|
||||
confirmAccept: 'تایید و قبول',
|
||||
noOffers: 'پیشنهادی موجود نیست',
|
||||
filters: 'فیلترها',
|
||||
allTokens: 'همه توکنها',
|
||||
allCurrencies: 'همه ارزها',
|
||||
prev: 'قبلی',
|
||||
next: 'بعدی',
|
||||
min: 'حداقل',
|
||||
max: 'حداکثر',
|
||||
trades: 'معامله',
|
||||
token: 'توکن',
|
||||
fiatCurrency: 'ارز',
|
||||
pricePerUnit: 'قیمت واحد',
|
||||
amount: 'مقدار',
|
||||
fiatTotal: 'جمع فیات',
|
||||
paymentMethod: 'روش پرداخت',
|
||||
selectPaymentMethod: 'روش پرداخت انتخاب کنید',
|
||||
paymentDetails: 'اطلاعات پرداخت',
|
||||
paymentDetailsPlaceholder: 'اطلاعات پرداخت خود را وارد کنید (حساب بانکی و غیره)',
|
||||
minOrder: 'حداقل سفارش',
|
||||
maxOrder: 'حداکثر سفارش',
|
||||
timeLimit: 'محدودیت زمانی',
|
||||
minutes: 'دقیقه',
|
||||
insufficientBalance: 'موجودی ناکافی',
|
||||
timeLimitWarning: 'بعد از قبول {minutes} دقیقه برای تکمیل پرداخت فرصت دارید.',
|
||||
tradeDetails: 'جزئیات معامله',
|
||||
tradeNotFound: 'معامله پیدا نشد',
|
||||
role: 'نقش',
|
||||
buyer: 'خریدار',
|
||||
seller: 'فروشنده',
|
||||
cryptoAmount: 'مقدار کریپتو',
|
||||
fiatAmount: 'مقدار فیات',
|
||||
timeline: 'جدول زمانی',
|
||||
tradeCreated: 'معامله ایجاد شد',
|
||||
paymentSent: 'پرداخت ارسال شد',
|
||||
paymentConfirmed: 'پرداخت تایید شد',
|
||||
tradeCompleted: 'معامله تکمیل شد',
|
||||
timeRemaining: 'زمان باقیمانده',
|
||||
expired: 'منقضی شده',
|
||||
markPaid: 'پرداخت کردم',
|
||||
confirmReceived: 'تایید دریافت پرداخت',
|
||||
cancelTrade: 'لغو معامله',
|
||||
status: {
|
||||
open: 'باز',
|
||||
paused: 'متوقف',
|
||||
locked: 'قفل شده',
|
||||
completed: 'تکمیل شده',
|
||||
cancelled: 'لغو شده',
|
||||
pending: 'در انتظار',
|
||||
payment_sent: 'پرداخت ارسال شد',
|
||||
disputed: 'اعتراض شده',
|
||||
refunded: 'بازپرداخت شده',
|
||||
},
|
||||
chat: 'چت',
|
||||
noMessages: 'هنوز پیامی نیست',
|
||||
messagePlaceholder: 'پیام بنویسید...',
|
||||
openDispute: 'اعتراض باز کنید',
|
||||
disputeWarning: 'باز کردن اعتراض معامله را متوقف میکند. یک مدیر بررسی خواهد کرد.',
|
||||
disputeCategory: 'دستهبندی',
|
||||
selectCategory: 'دستهبندی انتخاب کنید',
|
||||
disputeReason: 'دلیل',
|
||||
disputeReasonPlaceholder: 'مشکل را با جزئیات توضیح دهید (حداقل ۱۰ کاراکتر)',
|
||||
minChars: 'حداقل {count} کاراکتر',
|
||||
submitDispute: 'ارسال اعتراض',
|
||||
dispute: {
|
||||
payment_not_received: 'پرداخت دریافت نشد',
|
||||
wrong_amount: 'مبلغ اشتباه',
|
||||
fake_payment_proof: 'مدرک پرداخت جعلی',
|
||||
other: 'سایر',
|
||||
},
|
||||
noTrades: 'هنوز معاملهای نیست',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
@@ -316,7 +316,7 @@ const krd: Translations = {
|
||||
},
|
||||
|
||||
p2p: {
|
||||
title: 'P2P Dan\u00fbstandin',
|
||||
title: 'Danûstandina P2P',
|
||||
subtitle: 'Dan\u00fbstandina kr\u00eeto di navbera kesan de',
|
||||
firstTime: 'Cara yekem P2P bikar t\u00eenin?',
|
||||
steps: [
|
||||
@@ -326,7 +326,89 @@ const krd: Translations = {
|
||||
'Pi\u015ft\u00ee sazkirin\u00ea, h\u00fbn dikarin rasterast bigih\u00eejin P2P',
|
||||
],
|
||||
note: 'Malpera web\u00ea di pencereyek n\u00fb de vedibe. P\u00eav ajoya qeydk\u00eerin\u00ea li wir temam bikin.',
|
||||
button: 'P2P Veke',
|
||||
button: 'Platforma P2P Veke',
|
||||
buy: 'Bikire',
|
||||
sell: 'Bifiroşe',
|
||||
myAds: 'Reklamên Min',
|
||||
myTrades: 'Bazirganiyên Min',
|
||||
internalBalance: 'Balansa P2P',
|
||||
available: 'Berdest',
|
||||
locked: 'Qefilkirî',
|
||||
totalBalance: 'Tevahî',
|
||||
noBalance: 'Hê balans tune. Ji bo destpêkirinê depo bikin.',
|
||||
createOffer: 'Pêşniyar Biafirîne',
|
||||
acceptOffer: 'Pêşniyar Bipejirîne',
|
||||
confirmAccept: 'Bipejirîne & Qebûl Bike',
|
||||
noOffers: 'Pêşniyar tune ne',
|
||||
filters: 'Fîltre',
|
||||
allTokens: 'Hemû Token',
|
||||
allCurrencies: 'Hemû Dirav',
|
||||
prev: 'Berê',
|
||||
next: 'Pêş',
|
||||
min: 'Kêmtirîn',
|
||||
max: 'Herî zêde',
|
||||
trades: 'bazirganî',
|
||||
token: 'Token',
|
||||
fiatCurrency: 'Dirav',
|
||||
pricePerUnit: 'Bihayê yekîneyê',
|
||||
amount: 'Miqdar',
|
||||
fiatTotal: 'Bihayê Tevahî',
|
||||
paymentMethod: 'Rêbaza Dravdanê',
|
||||
selectPaymentMethod: 'Rêbaza dravdanê hilbijêrin',
|
||||
paymentDetails: 'Agahdariya Dravdanê',
|
||||
paymentDetailsPlaceholder: 'Agahdariya dravdanê binivîsin (hesabê bankê, hwd.)',
|
||||
minOrder: 'Kêmtirîn Sifarîş',
|
||||
maxOrder: 'Zêdetirîn Sifarîş',
|
||||
timeLimit: 'Sînorê Demê',
|
||||
minutes: 'deq',
|
||||
insufficientBalance: 'Balansa têr nîne',
|
||||
timeLimitWarning: 'Piştî qebûlkirinê {minutes} deqîqe hene ku dravdanê temam bikin.',
|
||||
tradeDetails: 'Hûrgiliyên Bazirganiyê',
|
||||
tradeNotFound: 'Bazirganî nehat dîtin',
|
||||
role: 'Rol',
|
||||
buyer: 'Kiriyar',
|
||||
seller: 'Firotkar',
|
||||
cryptoAmount: 'Mîqdara Krîpto',
|
||||
fiatAmount: 'Mîqdara Fîat',
|
||||
timeline: 'Demnîgar',
|
||||
tradeCreated: 'Bazirganî Hat Afirandin',
|
||||
paymentSent: 'Dravdan Hat Şandin',
|
||||
paymentConfirmed: 'Dravdan Hat Pejirandin',
|
||||
tradeCompleted: 'Bazirganî Qediya',
|
||||
timeRemaining: 'Dema mayî',
|
||||
expired: 'Dema wê derbas bû',
|
||||
markPaid: 'Min Drav Da',
|
||||
confirmReceived: 'Dravdanê Bipejirîne',
|
||||
cancelTrade: 'Bazirganiyê Betal Bike',
|
||||
status: {
|
||||
open: 'Vekirî',
|
||||
paused: 'Sekinandî',
|
||||
locked: 'Qefilkirî',
|
||||
completed: 'Qediyayî',
|
||||
cancelled: 'Betalkirî',
|
||||
pending: 'Li Bendê',
|
||||
payment_sent: 'Dravdan Şandî',
|
||||
disputed: 'Nakokî',
|
||||
refunded: 'Vegerandî',
|
||||
},
|
||||
chat: 'Sohbet',
|
||||
noMessages: 'Hê peyam tune',
|
||||
messagePlaceholder: 'Peyamekê binivîsin...',
|
||||
openDispute: 'Nakokî Veke',
|
||||
disputeWarning: 'Vekirina nakokiyê bazirganiyê dicemidîne. Moderatorek ê diyar bike.',
|
||||
disputeCategory: 'Kategorî',
|
||||
selectCategory: 'Kategorîyê hilbijêrin',
|
||||
disputeReason: 'Sedem',
|
||||
disputeReasonPlaceholder: 'Pirsgirêkê bi hûrgulî binivîsin (herî kêm 10 tîp)',
|
||||
minChars: 'Herî kêm {count} tîp',
|
||||
submitDispute: 'Nakokî Bişîne',
|
||||
dispute: {
|
||||
payment_not_received: 'Dravdan nehat wergirtin',
|
||||
wrong_amount: 'Mîqdara çewt',
|
||||
fake_payment_proof: 'Belgeya dravdanê ya sexte',
|
||||
other: 'Yên din',
|
||||
},
|
||||
noTrades: 'Hê bazirganî tune',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
@@ -305,16 +305,98 @@ const tr: Translations = {
|
||||
|
||||
p2p: {
|
||||
title: 'P2P Borsa',
|
||||
subtitle: 'Eşler arası kripto ticareti',
|
||||
firstTime: "P2P'yi ilk kez mi kullanıyorsunuz?",
|
||||
subtitle: 'Eşler arası kripto alım-satım',
|
||||
firstTime: 'P2P\'yi ilk kez mi kullanıyorsun?',
|
||||
steps: [
|
||||
'Web uygulamasını açmak için aşağıdaki düğmeye tıklayın',
|
||||
'Aşağıdaki butona tıklayarak web uygulamasını açın',
|
||||
'Hesap oluşturun veya giriş yapın',
|
||||
'P2P kurulum sürecini tamamlayın',
|
||||
"Kurulumdan sonra P2P'ye doğrudan erişebilirsiniz",
|
||||
'Kurulum sürecini tamamlayın',
|
||||
'Kurulumdan sonra P2P\'ye doğrudan erişebilirsiniz',
|
||||
],
|
||||
note: 'Web uygulaması yeni bir pencerede açılacak. Kayıt işlemini orada tamamlayın.',
|
||||
note: 'Web uygulaması yeni bir pencerede açılacaktır.',
|
||||
button: 'P2P Platformunu Aç',
|
||||
buy: 'Al',
|
||||
sell: 'Sat',
|
||||
myAds: 'İlanlarım',
|
||||
myTrades: 'İşlemlerim',
|
||||
internalBalance: 'P2P Bakiye',
|
||||
available: 'Kullanılabilir',
|
||||
locked: 'Kilitli',
|
||||
totalBalance: 'Toplam',
|
||||
noBalance: 'Henüz bakiye yok. İşlem yapmak için para yatırın.',
|
||||
createOffer: 'İlan Oluştur',
|
||||
acceptOffer: 'Teklifi Kabul Et',
|
||||
confirmAccept: 'Onayla ve Kabul Et',
|
||||
noOffers: 'Mevcut teklif yok',
|
||||
filters: 'Filtreler',
|
||||
allTokens: 'Tüm Tokenler',
|
||||
allCurrencies: 'Tüm Para Birimleri',
|
||||
prev: 'Önceki',
|
||||
next: 'Sonraki',
|
||||
min: 'Min',
|
||||
max: 'Maks',
|
||||
trades: 'işlem',
|
||||
token: 'Token',
|
||||
fiatCurrency: 'Para Birimi',
|
||||
pricePerUnit: 'Birim fiyat',
|
||||
amount: 'Miktar',
|
||||
fiatTotal: 'Fiat Toplamı',
|
||||
paymentMethod: 'Ödeme Yöntemi',
|
||||
selectPaymentMethod: 'Ödeme yöntemi seçin',
|
||||
paymentDetails: 'Ödeme Bilgileri',
|
||||
paymentDetailsPlaceholder: 'Ödeme bilgilerinizi girin (banka hesabı vb.)',
|
||||
minOrder: 'Min Sipariş',
|
||||
maxOrder: 'Maks Sipariş',
|
||||
timeLimit: 'Süre Limiti',
|
||||
minutes: 'dk',
|
||||
insufficientBalance: 'Yetersiz bakiye',
|
||||
timeLimitWarning: 'Kabul ettikten sonra ödemeyi tamamlamak için {minutes} dakikanız var.',
|
||||
tradeDetails: 'İşlem Detayları',
|
||||
tradeNotFound: 'İşlem bulunamadı',
|
||||
role: 'Rol',
|
||||
buyer: 'Alıcı',
|
||||
seller: 'Satıcı',
|
||||
cryptoAmount: 'Kripto Miktarı',
|
||||
fiatAmount: 'Fiat Miktarı',
|
||||
timeline: 'Zaman Çizelgesi',
|
||||
tradeCreated: 'İşlem Oluşturuldu',
|
||||
paymentSent: 'Ödeme Gönderildi',
|
||||
paymentConfirmed: 'Ödeme Onaylandı',
|
||||
tradeCompleted: 'İşlem Tamamlandı',
|
||||
timeRemaining: 'Kalan süre',
|
||||
expired: 'Süresi doldu',
|
||||
markPaid: 'Ödeme Yaptım',
|
||||
confirmReceived: 'Ödeme Alındığını Onayla',
|
||||
cancelTrade: 'İşlemi İptal Et',
|
||||
status: {
|
||||
open: 'Açık',
|
||||
paused: 'Duraklatılmış',
|
||||
locked: 'Kilitli',
|
||||
completed: 'Tamamlandı',
|
||||
cancelled: 'İptal Edildi',
|
||||
pending: 'Beklemede',
|
||||
payment_sent: 'Ödeme Gönderildi',
|
||||
disputed: 'İtirazlı',
|
||||
refunded: 'İade Edildi',
|
||||
},
|
||||
chat: 'Sohbet',
|
||||
noMessages: 'Henüz mesaj yok',
|
||||
messagePlaceholder: 'Mesaj yazın...',
|
||||
openDispute: 'İtiraz Aç',
|
||||
disputeWarning: 'İtiraz açmak işlemi dondurur. Bir moderatör inceleyecektir.',
|
||||
disputeCategory: 'Kategori',
|
||||
selectCategory: 'Kategori seçin',
|
||||
disputeReason: 'Sebep',
|
||||
disputeReasonPlaceholder: 'Sorunu ayrıntılı açıklayın (min 10 karakter)',
|
||||
minChars: 'En az {count} karakter',
|
||||
submitDispute: 'İtirazı Gönder',
|
||||
dispute: {
|
||||
payment_not_received: 'Ödeme alınmadı',
|
||||
wrong_amount: 'Yanlış miktar',
|
||||
fake_payment_proof: 'Sahte ödeme kanıtı',
|
||||
other: 'Diğer',
|
||||
},
|
||||
noTrades: 'Henüz işlem yok',
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
+93
-1
@@ -308,7 +308,7 @@ export interface Translations {
|
||||
trc20FeeWarning: string;
|
||||
};
|
||||
|
||||
// P2P Modal
|
||||
// P2P Section
|
||||
p2p: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -316,6 +316,98 @@ export interface Translations {
|
||||
steps: string[];
|
||||
note: string;
|
||||
button: string;
|
||||
// Tabs
|
||||
buy: string;
|
||||
sell: string;
|
||||
myAds: string;
|
||||
myTrades: string;
|
||||
// Balance
|
||||
internalBalance: string;
|
||||
available: string;
|
||||
locked: string;
|
||||
totalBalance: string;
|
||||
noBalance: string;
|
||||
// Offers
|
||||
createOffer: string;
|
||||
acceptOffer: string;
|
||||
confirmAccept: string;
|
||||
noOffers: string;
|
||||
filters: string;
|
||||
allTokens: string;
|
||||
allCurrencies: string;
|
||||
prev: string;
|
||||
next: string;
|
||||
min: string;
|
||||
max: string;
|
||||
trades: string;
|
||||
// Offer form
|
||||
token: string;
|
||||
fiatCurrency: string;
|
||||
pricePerUnit: string;
|
||||
amount: string;
|
||||
fiatTotal: string;
|
||||
paymentMethod: string;
|
||||
selectPaymentMethod: string;
|
||||
paymentDetails: string;
|
||||
paymentDetailsPlaceholder: string;
|
||||
minOrder: string;
|
||||
maxOrder: string;
|
||||
timeLimit: string;
|
||||
minutes: string;
|
||||
insufficientBalance: string;
|
||||
timeLimitWarning: string;
|
||||
// Trade
|
||||
tradeDetails: string;
|
||||
tradeNotFound: string;
|
||||
role: string;
|
||||
buyer: string;
|
||||
seller: string;
|
||||
cryptoAmount: string;
|
||||
fiatAmount: string;
|
||||
timeline: string;
|
||||
tradeCreated: string;
|
||||
paymentSent: string;
|
||||
paymentConfirmed: string;
|
||||
tradeCompleted: string;
|
||||
timeRemaining: string;
|
||||
expired: string;
|
||||
// Trade actions
|
||||
markPaid: string;
|
||||
confirmReceived: string;
|
||||
cancelTrade: string;
|
||||
// Status
|
||||
status: {
|
||||
open: string;
|
||||
paused: string;
|
||||
locked: string;
|
||||
completed: string;
|
||||
cancelled: string;
|
||||
pending: string;
|
||||
payment_sent: string;
|
||||
disputed: string;
|
||||
refunded: string;
|
||||
};
|
||||
// Chat
|
||||
chat: string;
|
||||
noMessages: string;
|
||||
messagePlaceholder: string;
|
||||
// Dispute
|
||||
openDispute: string;
|
||||
disputeWarning: string;
|
||||
disputeCategory: string;
|
||||
selectCategory: string;
|
||||
disputeReason: string;
|
||||
disputeReasonPlaceholder: string;
|
||||
minChars: string;
|
||||
submitDispute: string;
|
||||
dispute: {
|
||||
payment_not_received: string;
|
||||
wrong_amount: string;
|
||||
fake_payment_proof: string;
|
||||
other: string;
|
||||
};
|
||||
// No data
|
||||
noTrades: string;
|
||||
};
|
||||
|
||||
// Update Notification
|
||||
|
||||
@@ -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