mirror of
https://github.com/pezkuwichain/pezkuwi-sdk-ui.git
synced 2026-04-21 23:47:59 +00:00
285 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|