feat: integrate P2P trading into Telegram mini app

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