Team Notifications

PreviousNext

Team notifications and alerts with filtering and management.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/team/team-notifications.tsx
"use client";

import {
  Bell,
  Check,
  CheckCheck,
  MessageSquare,
  MoreVertical,
  X,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/registry/new-york/ui/avatar";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
  Empty,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/registry/new-york/ui/empty";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/registry/new-york/ui/select";
import { Separator } from "@/registry/new-york/ui/separator";

export type NotificationType =
  | "mention"
  | "ai_event"
  | "member_joined"
  | "file_shared"
  | "note_updated"
  | "project_updated"
  | "system";

export interface TeamNotification {
  id: string;
  type: NotificationType;
  title: string;
  message: string;
  user?: {
    id: string;
    name: string;
    avatar?: string;
  };
  link?: string;
  read: boolean;
  timestamp: Date;
  metadata?: Record<string, unknown>;
}

export interface TeamNotificationsProps {
  notifications?: TeamNotification[];
  onMarkAsRead?: (notificationId: string) => Promise<void>;
  onMarkAllAsRead?: () => Promise<void>;
  onDelete?: (notificationId: string) => Promise<void>;
  onClearAll?: () => Promise<void>;
  className?: string;
  showFilters?: boolean;
  unreadCount?: number;
}

function formatRelativeTime(date: Date): string {
  const now = Date.now();
  const diff = now - date.getTime();
  const minutes = Math.floor(diff / 60_000);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);

  if (minutes < 1) return "Just now";
  if (minutes < 60) return `${minutes}m ago`;
  if (hours < 24) return `${hours}h ago`;
  if (days < 7) return `${days}d ago`;
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
  }).format(date);
}

function getNotificationIcon(type: NotificationType) {
  switch (type) {
    case "mention":
      return MessageSquare;
    case "ai_event":
      return Bell;
    default:
      return Bell;
  }
}

function getInitials(name: string): string {
  return name
    .split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()
    .slice(0, 2);
}

export default function TeamNotifications({
  notifications = [],
  onMarkAsRead,
  onMarkAllAsRead,
  onDelete,
  onClearAll,
  className,
  showFilters = true,
  unreadCount,
}: TeamNotificationsProps) {
  const [typeFilter, setTypeFilter] = useState<string>("all");
  const [statusFilter, setStatusFilter] = useState<string>("all");

  const filteredNotifications = notifications.filter((notification) => {
    const matchesType =
      typeFilter === "all" || notification.type === typeFilter;
    const matchesStatus =
      statusFilter === "all" ||
      (statusFilter === "unread" && !notification.read) ||
      (statusFilter === "read" && notification.read);
    return matchesType && matchesStatus;
  });

  const unreadNotifications = filteredNotifications.filter((n) => !n.read);
  const displayUnreadCount = unreadCount ?? unreadNotifications.length;

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-4">
          <div className="flex flex-col flex-wrap gap-3 md:flex-row md:items-start md:justify-between">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              <CardTitle>Notifications</CardTitle>
              <CardDescription>
                {displayUnreadCount > 0 && (
                  <span className="text-primary">
                    {displayUnreadCount} unread
                  </span>
                )}
                {displayUnreadCount === 0 && "All caught up!"}
              </CardDescription>
            </div>
            <div className="flex gap-2">
              {onMarkAllAsRead && displayUnreadCount > 0 && (
                <Button
                  onClick={onMarkAllAsRead}
                  size="sm"
                  type="button"
                  variant="outline"
                >
                  <CheckCheck className="size-4" />
                  Mark all read
                </Button>
              )}
              {onClearAll && (
                <Button
                  onClick={onClearAll}
                  size="sm"
                  type="button"
                  variant="outline"
                >
                  <X className="size-4" />
                  Clear all
                </Button>
              )}
            </div>
          </div>
          {showFilters && (
            <div className="flex flex-wrap gap-2">
              <Select onValueChange={setTypeFilter} value={typeFilter}>
                <SelectTrigger className="w-full md:w-[140px]">
                  <SelectValue placeholder="All types" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="all">All types</SelectItem>
                  <SelectItem value="mention">Mentions</SelectItem>
                  <SelectItem value="ai_event">AI Events</SelectItem>
                  <SelectItem value="member_joined">Members</SelectItem>
                  <SelectItem value="file_shared">Files</SelectItem>
                  <SelectItem value="note_updated">Notes</SelectItem>
                  <SelectItem value="project_updated">Projects</SelectItem>
                  <SelectItem value="system">System</SelectItem>
                </SelectContent>
              </Select>
              <Select onValueChange={setStatusFilter} value={statusFilter}>
                <SelectTrigger className="w-full md:w-[140px]">
                  <SelectValue placeholder="All statuses" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="all">All</SelectItem>
                  <SelectItem value="unread">Unread</SelectItem>
                  <SelectItem value="read">Read</SelectItem>
                </SelectContent>
              </Select>
            </div>
          )}
        </div>
      </CardHeader>
      <CardContent>
        {filteredNotifications.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Bell className="size-6" />
              </EmptyMedia>
              <EmptyTitle>
                {typeFilter !== "all" || statusFilter !== "all"
                  ? "No notifications match your filters"
                  : "No notifications yet"}
              </EmptyTitle>
            </EmptyHeader>
          </Empty>
        ) : (
          <div className="flex flex-col gap-0">
            {filteredNotifications.map((notification, idx) => {
              const Icon = getNotificationIcon(notification.type);
              const isFirst = idx === 0;
              const isLast = idx === filteredNotifications.length - 1;
              return (
                <div key={notification.id}>
                  <div
                    className={cn(
                      "flex items-start gap-4 p-4 transition-colors",
                      !notification.read && "bg-primary/5",
                      "hover:bg-muted/50",
                      isFirst && "rounded-t-lg",
                      isLast && "rounded-b-lg"
                    )}
                  >
                    <div
                      className={cn(
                        "flex size-10 shrink-0 items-center justify-center rounded-full",
                        notification.read
                          ? "bg-muted text-muted-foreground"
                          : "bg-primary/10 text-primary"
                      )}
                    >
                      {notification.user ? (
                        <Avatar className="size-10">
                          <AvatarImage
                            alt={notification.user.name}
                            src={notification.user.avatar}
                          />
                          <AvatarFallback>
                            {getInitials(notification.user.name)}
                          </AvatarFallback>
                        </Avatar>
                      ) : (
                        <Icon className="size-5" />
                      )}
                    </div>
                    <div className="flex min-w-0 flex-1 flex-col gap-2">
                      <div className="flex flex-wrap items-center gap-1">
                        <span className="font-medium text-sm">
                          {notification.title}
                        </span>
                        {!notification.read && (
                          <div className="size-2 shrink-0 rounded-full bg-primary" />
                        )}
                        <Badge className="text-xs" variant="outline">
                          {notification.type}
                        </Badge>
                      </div>
                      <p className="wrap-break-word text-muted-foreground text-sm">
                        {notification.message}
                      </p>
                      <span className="text-muted-foreground text-xs">
                        {formatRelativeTime(notification.timestamp)}
                      </span>
                    </div>
                    <DropdownMenu>
                      <DropdownMenuTrigger asChild>
                        <Button
                          aria-label="More options"
                          size="icon-sm"
                          type="button"
                          variant="ghost"
                        >
                          <MoreVertical className="size-4" />
                        </Button>
                      </DropdownMenuTrigger>
                      <DropdownMenuContent align="end">
                        {!notification.read && onMarkAsRead && (
                          <DropdownMenuItem
                            onClick={() => onMarkAsRead(notification.id)}
                          >
                            <Check className="size-4" />
                            Mark as read
                          </DropdownMenuItem>
                        )}
                        {notification.link && (
                          <DropdownMenuItem asChild>
                            <a href={notification.link}>
                              <MessageSquare className="size-4" />
                              View
                            </a>
                          </DropdownMenuItem>
                        )}
                        {onDelete && (
                          <>
                            <DropdownMenuSeparator />
                            <DropdownMenuItem
                              onSelect={() => onDelete(notification.id)}
                              variant="destructive"
                            >
                              <X className="size-4" />
                              Delete
                            </DropdownMenuItem>
                          </>
                        )}
                      </DropdownMenuContent>
                    </DropdownMenu>
                  </div>
                  {idx < filteredNotifications.length - 1 && <Separator />}
                </div>
              );
            })}
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @hextaui/team-notifications

Usage

import { TeamNotifications } from "@/components/ui/team-notifications"
<TeamNotifications />