feat: add P2P messages inbox and OKX-style navigation

- Add P2PMessages inbox page listing all trade conversations
- Update P2PDashboard top nav with icon+label buttons (Orders, Ads, Messages)
- Add unread message count badge with realtime subscription
- Add /p2p/messages route
- Add i18n translations for all 6 locales
This commit is contained in:
2026-02-24 03:24:28 +03:00
parent 4536c454a4
commit f9119943e9
9 changed files with 431 additions and 17 deletions
+6
View File
@@ -42,6 +42,7 @@ const P2PTrade = lazy(() => import('./pages/P2PTrade'));
const P2POrders = lazy(() => import('./pages/P2POrders'));
const P2PDispute = lazy(() => import('./pages/P2PDispute'));
const P2PMerchantDashboard = lazy(() => import('./pages/P2PMerchantDashboard'));
const P2PMessages = lazy(() => import('./pages/P2PMessages'));
const DEXDashboard = lazy(() => import('./components/dex/DEXDashboard').then(m => ({ default: m.DEXDashboard })));
const Presale = lazy(() => import('./pages/Presale'));
const PresaleList = lazy(() => import('./pages/launchpad/PresaleList'));
@@ -199,6 +200,11 @@ function App() {
<P2PLayout><P2PDispute /></P2PLayout>
</ProtectedRoute>
} />
<Route path="/p2p/messages" element={
<ProtectedRoute allowTelegramSession>
<P2PLayout><P2PMessages /></P2PLayout>
</ProtectedRoute>
} />
<Route path="/p2p/merchant" element={
<ProtectedRoute allowTelegramSession>
<P2PLayout><P2PMerchantDashboard /></P2PLayout>
+92 -17
View File
@@ -5,7 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks } from 'lucide-react';
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks, MessageSquare } from 'lucide-react';
import { AdList } from './AdList';
import { CreateAd } from './CreateAd';
import { NotificationBell } from './NotificationBell';
@@ -36,10 +36,75 @@ export function P2PDashboard() {
const navigate = useNavigate();
const { userId } = useP2PIdentity();
const [unreadMessages, setUnreadMessages] = useState(0);
const handleBalanceUpdated = () => {
setBalanceRefreshKey(prev => prev + 1);
};
// Fetch unread message count
useEffect(() => {
const fetchUnread = async () => {
if (!userId) return;
try {
// Get all trade IDs where user is participant
const { data: trades } = await supabase
.from('p2p_fiat_trades')
.select('id')
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`);
if (!trades || trades.length === 0) return;
const tradeIds = trades.map(t => t.id);
const { count } = await supabase
.from('p2p_messages')
.select('*', { count: 'exact', head: true })
.in('trade_id', tradeIds)
.neq('sender_id', userId)
.eq('is_read', false);
setUnreadMessages(count || 0);
} catch (error) {
console.error('Fetch unread count error:', error);
}
};
fetchUnread();
// Realtime subscription for new messages
const channel = supabase
.channel('p2p-unread-count')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'p2p_messages',
},
() => {
fetchUnread();
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'p2p_messages',
},
() => {
fetchUnread();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
// Fetch user stats
useEffect(() => {
const fetchStats = async () => {
@@ -93,29 +158,39 @@ export function P2PDashboard() {
<Home className="w-4 h-4 mr-2" />
{t('p2p.backToHome')}
</Button>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 sm:gap-3">
<NotificationBell />
<Button
variant="outline"
onClick={() => navigate('/p2p/merchant')}
className="border-gray-700 hover:bg-gray-800"
>
<Store className="w-4 h-4 mr-2" />
{t('p2p.merchant')}
</Button>
<Button
variant="outline"
<button
onClick={() => navigate('/p2p/orders')}
className="border-gray-700 hover:bg-gray-800"
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
>
<ClipboardList className="w-4 h-4 mr-2" />
{t('p2p.myTrades')}
<ClipboardList className="w-5 h-5 text-gray-300" />
<span className="text-[10px] text-gray-400">{t('p2pNav.orders')}</span>
{userStats.activeTrades > 0 && (
<Badge className="ml-2 bg-yellow-500 text-black">
<Badge className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] px-1.5 min-w-[18px] h-[18px] flex items-center justify-center">
{userStats.activeTrades}
</Badge>
)}
</Button>
</button>
<button
onClick={() => navigate('/p2p/merchant')}
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
>
<Store className="w-5 h-5 text-gray-300" />
<span className="text-[10px] text-gray-400">{t('p2pNav.ads')}</span>
</button>
<button
onClick={() => navigate('/p2p/messages')}
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
>
<MessageSquare className="w-5 h-5 text-gray-300" />
<span className="text-[10px] text-gray-400">{t('p2pNav.messages')}</span>
{unreadMessages > 0 && (
<Badge className="absolute -top-1 -right-1 bg-green-500 text-white text-[10px] px-1.5 min-w-[18px] h-[18px] flex items-center justify-center">
{unreadMessages}
</Badge>
)}
</button>
</div>
</div>
+13
View File
@@ -1553,6 +1553,19 @@ export default {
'p2pChat.newMessage': 'رسالة جديدة',
'p2pChat.newImage': 'صورة جديدة',
// P2P Navigation
'p2pNav.orders': 'الطلبات',
'p2pNav.ads': 'الإعلانات',
'p2pNav.messages': 'الرسائل',
// P2P Messages Inbox
'p2pMessages.title': 'الرسائل',
'p2pMessages.subtitle': 'محادثات التداول الخاصة بك',
'p2pMessages.noConversations': 'لا توجد محادثات بعد',
'p2pMessages.noConversationsHint': 'ابدأ صفقة لبدء المحادثة',
'p2pMessages.you': 'أنت',
'p2pMessages.sentImage': 'أرسل صورة',
'p2pFilters.filters': 'الفلاتر',
'p2pFilters.filterOrders': 'فلترة الطلبات',
'p2pFilters.reset': 'إعادة تعيين',
+13
View File
@@ -1543,6 +1543,19 @@ export default {
'p2pChat.newMessage': 'نامەی نوێ',
'p2pChat.newImage': 'وێنەی نوێ',
// P2P Navigation
'p2pNav.orders': 'داواکاریەکان',
'p2pNav.ads': 'ڕیکلامەکان',
'p2pNav.messages': 'پەیامەکان',
// P2P Messages Inbox
'p2pMessages.title': 'پەیامەکان',
'p2pMessages.subtitle': 'گفتوگۆکانی بازرگانیت',
'p2pMessages.noConversations': 'هێشتا گفتوگۆ نییە',
'p2pMessages.noConversationsHint': 'بۆ دەستپێکردنی گفتوگۆ بازرگانییەک بکە',
'p2pMessages.you': 'تۆ',
'p2pMessages.sentImage': 'وێنەیەکی ناردووە',
'p2pFilters.filters': 'فلتەرەکان',
'p2pFilters.filterOrders': 'داواکارییەکان فلتەر بکە',
'p2pFilters.reset': 'ڕیسێت',
+13
View File
@@ -1907,6 +1907,19 @@ export default {
'p2pChat.newMessage': 'New Message',
'p2pChat.newImage': 'New Image',
// P2P Navigation
'p2pNav.orders': 'Orders',
'p2pNav.ads': 'Ads',
'p2pNav.messages': 'Messages',
// P2P Messages Inbox
'p2pMessages.title': 'Messages',
'p2pMessages.subtitle': 'Your trade conversations',
'p2pMessages.noConversations': 'No conversations yet',
'p2pMessages.noConversationsHint': 'Start a trade to begin chatting',
'p2pMessages.you': 'You',
'p2pMessages.sentImage': 'Sent an image',
// OrderFilters
'p2pFilters.filters': 'Filters',
'p2pFilters.filterOrders': 'Filter Orders',
+13
View File
@@ -1577,6 +1577,19 @@ export default {
'p2pChat.newMessage': 'پیام جدید',
'p2pChat.newImage': 'تصویر جدید',
// P2P Navigation
'p2pNav.orders': 'سفارشات',
'p2pNav.ads': 'آگهی‌ها',
'p2pNav.messages': 'پیام‌ها',
// P2P Messages Inbox
'p2pMessages.title': 'پیام‌ها',
'p2pMessages.subtitle': 'گفتگوهای معاملاتی شما',
'p2pMessages.noConversations': 'هنوز گفتگویی وجود ندارد',
'p2pMessages.noConversationsHint': 'برای شروع گفتگو یک معامله آغاز کنید',
'p2pMessages.you': 'شما',
'p2pMessages.sentImage': 'تصویری ارسال شد',
// OrderFilters
'p2pFilters.filters': 'فیلترها',
'p2pFilters.filterOrders': 'فیلتر سفارشات',
+13
View File
@@ -1565,6 +1565,19 @@ export default {
'p2pChat.newMessage': 'Peyama Nû',
'p2pChat.newImage': 'Wêneya Nû',
// P2P Navigation
'p2pNav.orders': 'Ferman',
'p2pNav.ads': 'Reklam',
'p2pNav.messages': 'Peyam',
// P2P Messages Inbox
'p2pMessages.title': 'Peyam',
'p2pMessages.subtitle': 'Sohbetên bazirganiya we',
'p2pMessages.noConversations': 'Hêj sohbet tune',
'p2pMessages.noConversationsHint': 'Ji bo destpêkirina sohbetê bazirganiyekê bikin',
'p2pMessages.you': 'Tu',
'p2pMessages.sentImage': 'Wêneyek şand',
// OrderFilters
'p2pFilters.filters': 'Fîltre',
'p2pFilters.filterOrders': 'Siparîşan Fîltre Bike',
+13
View File
@@ -1559,6 +1559,19 @@ export default {
'p2pChat.newMessage': 'Yeni Mesaj',
'p2pChat.newImage': 'Yeni Resim',
// P2P Navigation
'p2pNav.orders': 'Siparişler',
'p2pNav.ads': 'İlanlar',
'p2pNav.messages': 'Mesajlar',
// P2P Messages Inbox
'p2pMessages.title': 'Mesajlar',
'p2pMessages.subtitle': 'Trade sohbetleriniz',
'p2pMessages.noConversations': 'Henüz sohbet yok',
'p2pMessages.noConversationsHint': 'Sohbet başlatmak için bir trade başlatın',
'p2pMessages.you': 'Sen',
'p2pMessages.sentImage': 'Bir resim gönderdi',
// OrderFilters
'p2pFilters.filters': 'Filtreler',
'p2pFilters.filterOrders': 'Siparişleri Filtrele',
+255
View File
@@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import {
ArrowLeft,
MessageSquare,
Loader2,
Image as ImageIcon,
} from 'lucide-react';
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
import { supabase } from '@/lib/supabase';
interface TradeConversation {
tradeId: string;
counterpartyId: string;
counterpartyWallet: string;
lastMessage: string;
lastMessageType: 'text' | 'image' | 'system';
lastMessageAt: string;
lastMessageSenderId: string;
unreadCount: number;
tradeStatus: string;
}
export default function P2PMessages() {
const navigate = useNavigate();
const { t } = useTranslation();
const { userId } = useP2PIdentity();
const [conversations, setConversations] = useState<TradeConversation[]>([]);
const [loading, setLoading] = useState(true);
const fetchConversations = useCallback(async () => {
if (!userId) {
setLoading(false);
return;
}
try {
// 1. Get all trades where user is buyer or seller
const { data: trades, error: tradesError } = await supabase
.from('p2p_fiat_trades')
.select('id, seller_id, buyer_id, buyer_wallet, offer_id, status')
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
.order('created_at', { ascending: false });
if (tradesError) throw tradesError;
if (!trades || trades.length === 0) {
setConversations([]);
setLoading(false);
return;
}
const tradeIds = trades.map(t => t.id);
// 2. Get all messages for these trades
const { data: messages, error: msgsError } = await supabase
.from('p2p_messages')
.select('id, trade_id, sender_id, message, message_type, is_read, created_at')
.in('trade_id', tradeIds)
.order('created_at', { ascending: false });
if (msgsError) throw msgsError;
// 3. Get offer data for seller_wallet resolution
const offerIds = [...new Set(trades.map(t => t.offer_id))];
const { data: offers } = await supabase
.from('p2p_fiat_offers')
.select('id, seller_wallet')
.in('id', offerIds);
const offerMap = new Map(offers?.map(o => [o.id, o.seller_wallet]) || []);
// 4. Group messages by trade_id and build conversation list
const convos: TradeConversation[] = [];
for (const trade of trades) {
const tradeMessages = messages?.filter(m => m.trade_id === trade.id) || [];
if (tradeMessages.length === 0) continue; // Skip trades with no messages
const lastMsg = tradeMessages[0]; // Already sorted desc
const unreadCount = tradeMessages.filter(
m => m.sender_id !== userId && !m.is_read
).length;
// Resolve counterparty
const isBuyer = trade.buyer_id === userId;
const counterpartyId = isBuyer ? trade.seller_id : trade.buyer_id;
const counterpartyWallet = isBuyer
? (offerMap.get(trade.offer_id) || '???')
: trade.buyer_wallet;
convos.push({
tradeId: trade.id,
counterpartyId,
counterpartyWallet,
lastMessage: lastMsg.message,
lastMessageType: lastMsg.message_type,
lastMessageAt: lastMsg.created_at,
lastMessageSenderId: lastMsg.sender_id,
unreadCount,
tradeStatus: trade.status,
});
}
// Sort by last message time (newest first)
convos.sort((a, b) =>
new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()
);
setConversations(convos);
} catch (error) {
console.error('Fetch conversations error:', error);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
// Realtime subscription for new messages
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel('p2p-messages-inbox')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'p2p_messages',
},
() => {
// Refetch conversations when any new message arrives
fetchConversations();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId, fetchConversations]);
const formatRelativeTime = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return t('p2p.justNow');
if (diffMin < 60) return `${diffMin}m`;
if (diffHr < 24) return `${diffHr}h`;
if (diffDay < 7) return `${diffDay}d`;
return date.toLocaleDateString();
};
const truncateWallet = (wallet: string) => {
if (wallet.length <= 10) return wallet;
return `${wallet.slice(0, 6)}...${wallet.slice(-4)}`;
};
const getMessagePreview = (convo: TradeConversation) => {
const isOwn = convo.lastMessageSenderId === userId;
const prefix = isOwn ? `${t('p2pMessages.you')}: ` : '';
if (convo.lastMessageType === 'image') {
return `${prefix}${t('p2pMessages.sentImage')}`;
}
const msg = convo.lastMessage.length > 40
? convo.lastMessage.slice(0, 40) + '...'
: convo.lastMessage;
return `${prefix}${msg}`;
};
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-3 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/p2p')}
className="text-gray-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-white">{t('p2pMessages.title')}</h1>
<p className="text-sm text-gray-400">{t('p2pMessages.subtitle')}</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
) : conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
<MessageSquare className="w-12 h-12 mb-3" />
<p className="text-lg font-medium">{t('p2pMessages.noConversations')}</p>
<p className="text-sm">{t('p2pMessages.noConversationsHint')}</p>
</div>
) : (
<div className="space-y-1">
{conversations.map((convo) => (
<button
key={convo.tradeId}
onClick={() => navigate(`/p2p/trade/${convo.tradeId}`)}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800/60 transition-colors text-left"
>
<Avatar className="h-10 w-10 shrink-0">
<AvatarFallback className="bg-gray-700 text-sm">
{convo.counterpartyWallet.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-white truncate">
{truncateWallet(convo.counterpartyWallet)}
</span>
<span className="text-xs text-gray-500 shrink-0">
{formatRelativeTime(convo.lastMessageAt)}
</span>
</div>
<div className="flex items-center justify-between gap-2 mt-0.5">
<span className={`text-xs truncate ${convo.unreadCount > 0 ? 'text-gray-300 font-medium' : 'text-gray-500'}`}>
{convo.lastMessageType === 'image' && (
<ImageIcon className="w-3 h-3 inline mr-1" />
)}
{getMessagePreview(convo)}
</span>
{convo.unreadCount > 0 && (
<Badge className="bg-green-500 text-white text-[10px] px-1.5 py-0 min-w-[18px] h-[18px] flex items-center justify-center shrink-0">
{convo.unreadCount}
</Badge>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}