Repository Public
1'use client';2import Link from 'next/link';3import * as Popover from '@radix-ui/react-popover';4import { Bell, CheckCheck } from 'lucide-react';5import { useQueryClient } from '@tanstack/react-query';6import type { Notification } from '@/lib/dto-auth';7import { api, errorMessage } from '@/lib/api';8import { qk, useNotifications } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { useNow } from '@/hooks/use-viewport';11import { fmtRelative } from '@/lib/format';12import { Button } from '@/components/ui/button';13import { EmptyState, ErrorState } from '@/components/ui/misc';14import { Dot, type Tone } from '@/components/ui/badge';15import { toast } from '@/stores/toasts';16import { cn } from '@/lib/cn';1718const toneOf = (kind: string): Tone => (/fail|error/.test(kind) ? 'error' : /wait|approval|question|budget/.test(kind) ? 'info' : /done|succe/.test(kind) ? 'success' : 'accent');1920export function NotificationsBell({ className }: { className?: string }) {21 const open = useUi((s) => s.notificationsOpen);22 const setUi = useUi((s) => s.set);23 const q = useNotifications();24 const qc = useQueryClient();25 const now = useNow(30_000);26 const list = q.data ?? [];27 const unread = list.filter((n) => !n.readAt).length;28 const markRead = async (ids?: string[]) => {29 const at = new Date().toISOString();30 qc.setQueryData<Notification[]>(qk.notifications, (l) => l?.map((n) => (!n.readAt && (!ids || ids.includes(n.id)) ? { ...n, readAt: at } : n)));31 try {32 await api.post('/notifications/read', ids ? { ids } : { all: true });33 } catch (e) {34 toast.error('Could not mark as read', errorMessage(e));35 void qc.invalidateQueries({ queryKey: qk.notifications });36 }37 };38 return (39 <Popover.Root open={open} onOpenChange={(o) => setUi({ notificationsOpen: o })}>40 <Popover.Trigger asChild>41 <button42 aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}43 data-testid="notifications-bell"44 className={cn('relative inline-flex size-8 shrink-0 items-center justify-center rounded-sm text-text-2 hover:bg-surface-2 hover:text-text pointer-coarse:size-11', className)}45 >46 <Bell className="size-4" />47 {unread > 0 && (48 <span className="tnum absolute right-0.5 top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-accent px-0.5 text-[9px] font-bold text-accent-fg pointer-coarse:right-1.5 pointer-coarse:top-1.5">49 {unread > 99 ? '99+' : unread}50 </span>51 )}52 </button>53 </Popover.Trigger>54 <Popover.Portal>55 <Popover.Content align="end" sideOffset={8} collisionPadding={8} className="anim-fade z-[70] flex max-h-[min(70dvh,520px)] w-[min(380px,calc(100vw-16px))] flex-col rounded-md border border-line-2 bg-elevated shadow-pop focus:outline-none">56 <div className="flex items-center gap-2 border-b border-line px-3 py-2">57 <p className="flex-1 text-xs font-semibold">Notifications</p>58 <Button size="sm" variant="ghost" disabled={!unread} onClick={() => markRead()}>59 <CheckCheck className="size-3.5" /> Mark all read60 </Button>61 </div>62 <div className="min-h-0 flex-1 overflow-y-auto p-1">63 {q.isError ? (64 <ErrorState error={q.error} onRetry={() => q.refetch()} />65 ) : list.length === 0 ? (66 <EmptyState title="No notifications" description="Approvals, questions and finished missions will appear here." className="py-8" />67 ) : (68 list.map((n) => {69 const inner = (70 <>71 <Dot tone={n.readAt ? 'muted' : toneOf(n.kind)} className="mt-1.5" />72 <span className="min-w-0 flex-1">73 <span className={cn('block text-[13px]', n.readAt ? 'text-text-2' : 'font-medium text-text')}>{n.title}</span>74 <span className="block text-xs text-text-2">{n.body}</span>75 <span className="tnum mt-0.5 block text-[11px] text-muted">{fmtRelative(n.createdAt, now)}</span>76 </span>77 </>78 );79 const cls = 'flex w-full items-start gap-2.5 rounded-sm px-2 py-2 text-left hover:bg-hover';80 return n.link ? (81 <Link key={n.id} href={n.link} className={cls} onClick={() => { void markRead([n.id]); setUi({ notificationsOpen: false }); }}>82 {inner}83 </Link>84 ) : (85 <button key={n.id} className={cls} onClick={() => markRead([n.id])}>86 {inner}87 </button>88 );89 })90 )}91 </div>92 </Popover.Content>93 </Popover.Portal>94 </Popover.Root>95 );96}97