Files
pezkuwi-sdk-ui/packages/apps/src/components/p2p/NotificationBell.tsx
T

285 lines
8.5 KiB
TypeScript

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 trades
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}