feat(notifications): header notification bell
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
['year', 365 * 24 * 60 * 60 * 1000],
|
||||
['month', 30 * 24 * 60 * 60 * 1000],
|
||||
['day', 24 * 60 * 60 * 1000],
|
||||
['hour', 60 * 60 * 1000],
|
||||
['minute', 60 * 1000],
|
||||
];
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const delta = timestamp - Date.now();
|
||||
const magnitude = Math.abs(delta);
|
||||
for (const [unit, ms] of RELATIVE_UNITS) {
|
||||
if (magnitude >= ms) {
|
||||
return relativeFormatter.format(Math.round(delta / ms), unit);
|
||||
}
|
||||
}
|
||||
return 'just now';
|
||||
};
|
||||
|
||||
export const NotificationBell = () => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const unreadCount = useQuery(
|
||||
api.notifications.unreadCount,
|
||||
isAuthenticated ? {} : 'skip',
|
||||
);
|
||||
const notifications = useQuery(
|
||||
api.notifications.listMine,
|
||||
isAuthenticated ? { limit: 10 } : 'skip',
|
||||
);
|
||||
const markRead = useMutation(api.notifications.markRead);
|
||||
const markAllRead = useMutation(api.notifications.markAllRead);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = unreadCount ?? 0;
|
||||
const badgeLabel = count > 9 ? '9+' : String(count);
|
||||
const items = notifications ?? [];
|
||||
|
||||
// Fire-and-forget: never block navigation on the read write, but surface a
|
||||
// toast if it fails so we don't silently assume success.
|
||||
const handleMarkRead = async (notificationId: Id<'notifications'>) => {
|
||||
try {
|
||||
await markRead({ notificationId });
|
||||
} catch {
|
||||
toast.error('Could not mark the notification as read.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await markAllRead({});
|
||||
} catch {
|
||||
toast.error('Could not mark notifications as read.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='relative'
|
||||
aria-label='Notifications'
|
||||
>
|
||||
<Bell className='h-5 w-5' />
|
||||
{count > 0 && (
|
||||
<span className='bg-primary text-primary-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold'>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-80'>
|
||||
<div className='flex items-center justify-between gap-2 px-2 py-1.5'>
|
||||
<DropdownMenuLabel className='p-0'>Notifications</DropdownMenuLabel>
|
||||
{count > 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
className='text-muted-foreground hover:text-foreground cursor-pointer text-xs'
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{items.length === 0 ? (
|
||||
<p className='text-muted-foreground px-2 py-6 text-center text-sm'>
|
||||
No notifications yet.
|
||||
</p>
|
||||
) : (
|
||||
items.map((notification) => (
|
||||
<DropdownMenuItem key={notification._id} asChild>
|
||||
<Link
|
||||
href={notification.link ?? '#'}
|
||||
onClick={() => void handleMarkRead(notification._id)}
|
||||
className='flex w-full cursor-pointer flex-col items-start gap-0.5'
|
||||
>
|
||||
<span className='flex w-full items-center gap-2'>
|
||||
<span className='flex-1 truncate text-sm font-medium'>
|
||||
{notification.title}
|
||||
</span>
|
||||
{notification.readAt === undefined && (
|
||||
<span
|
||||
className='bg-primary size-2 shrink-0 rounded-full'
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className='text-muted-foreground line-clamp-2 text-xs'>
|
||||
{notification.body}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-[11px]'>
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user