diff --git a/web/src/App.tsx b/web/src/App.tsx index ddbfd883..fec8e160 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> + + + + } /> diff --git a/web/src/components/p2p/P2PDashboard.tsx b/web/src/components/p2p/P2PDashboard.tsx index 5a30b68a..da4ed1d7 100644 --- a/web/src/components/p2p/P2PDashboard.tsx +++ b/web/src/components/p2p/P2PDashboard.tsx @@ -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() { {t('p2p.backToHome')} -
+
- - + + +
diff --git a/web/src/i18n/locales/ar.ts b/web/src/i18n/locales/ar.ts index 5bc754e5..23c69e5f 100644 --- a/web/src/i18n/locales/ar.ts +++ b/web/src/i18n/locales/ar.ts @@ -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': 'إعادة تعيين', diff --git a/web/src/i18n/locales/ckb.ts b/web/src/i18n/locales/ckb.ts index c5100b92..39cb2a13 100644 --- a/web/src/i18n/locales/ckb.ts +++ b/web/src/i18n/locales/ckb.ts @@ -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': 'ڕیسێت', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index ec15945c..4d473bf2 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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', diff --git a/web/src/i18n/locales/fa.ts b/web/src/i18n/locales/fa.ts index eb745656..e5a20268 100644 --- a/web/src/i18n/locales/fa.ts +++ b/web/src/i18n/locales/fa.ts @@ -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': 'فیلتر سفارشات', diff --git a/web/src/i18n/locales/kmr.ts b/web/src/i18n/locales/kmr.ts index 9d818f92..7de96384 100644 --- a/web/src/i18n/locales/kmr.ts +++ b/web/src/i18n/locales/kmr.ts @@ -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', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 5ad5e90c..17173883 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -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', diff --git a/web/src/pages/P2PMessages.tsx b/web/src/pages/P2PMessages.tsx new file mode 100644 index 00000000..f0e5dbc3 --- /dev/null +++ b/web/src/pages/P2PMessages.tsx @@ -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([]); + 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 ( +
+
+ +
+

{t('p2pMessages.title')}

+

{t('p2pMessages.subtitle')}

+
+
+ + {loading ? ( +
+ +
+ ) : conversations.length === 0 ? ( +
+ +

{t('p2pMessages.noConversations')}

+

{t('p2pMessages.noConversationsHint')}

+
+ ) : ( +
+ {conversations.map((convo) => ( + + ))} +
+ )} +
+ ); +}