mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 10:17:54 +00:00
feat(p2p): add Phase 3 dispute system components
- Add DisputeModal.tsx with reason selection, evidence upload, terms acceptance - Add P2PDispute.tsx page with evidence gallery, status timeline, real-time updates - Integrate dispute button in P2PTrade.tsx - Add /p2p/dispute/:disputeId route to App.tsx - Add P2P test suite with MockStore pattern (32 tests passing) - Update P2P-BUILDING-PLAN.md with Phase 3 progress (70% complete) - Fix lint errors in test files and components
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
DollarSign,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Loader2,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch notifications
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setNotifications(data || []);
|
||||
setUnreadCount(data?.filter(n => !n.is_read).length || 0);
|
||||
} catch (error) {
|
||||
console.error('Fetch notifications error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`notifications-${user.id}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_notifications',
|
||||
filter: `user_id=eq.${user.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
const newNotif = payload.new as Notification;
|
||||
setNotifications(prev => [newNotif, ...prev.slice(0, 19)]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Mark as read
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId);
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notificationId ? { ...n, is_read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error('Mark as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const markAllAsRead = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_read', false);
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch (error) {
|
||||
console.error('Mark all as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification click
|
||||
const handleClick = (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Navigate to reference
|
||||
if (notification.reference_type === 'trade' && notification.reference_id) {
|
||||
navigate(`/p2p/trade/${notification.reference_id}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for notification type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
return <MessageSquare className="w-4 h-4 text-blue-400" />;
|
||||
case 'payment_sent':
|
||||
return <DollarSign className="w-4 h-4 text-yellow-400" />;
|
||||
case 'payment_confirmed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||
case 'trade_cancelled':
|
||||
return <XCircle className="w-4 h-4 text-gray-400" />;
|
||||
case 'dispute_opened':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-400" />;
|
||||
case 'new_rating':
|
||||
return <Star className="w-4 h-4 text-yellow-400" />;
|
||||
case 'new_order':
|
||||
return <DollarSign className="w-4 h-4 text-green-400" />;
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (seconds < 60) return 'Just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-gray-400 hover:text-white"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-red-500 text-white text-xs rounded-full">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 bg-gray-900 border-gray-800"
|
||||
>
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span className="text-white">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-gray-400 hover:text-white h-auto py-1"
|
||||
>
|
||||
<CheckCheck className="w-3 h-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<Bell className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
onClick={() => handleClick(notification)}
|
||||
className={`
|
||||
flex items-start gap-3 p-3 cursor-pointer
|
||||
${!notification.is_read ? 'bg-gray-800/50' : ''}
|
||||
hover:bg-gray-800
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5">{getIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'text-white font-medium' : 'text-gray-300'}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate('/p2p/orders');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="justify-center text-gray-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
View all orders
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user