SPB Git

Repository Public

notifications.tsx 4.8 KB 97 lines tsx

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
Files main
Menu

Theme