All components

Team Notifications

Team Notifications is a React and Tailwind CSS feedback component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT feedback

What it is

Team Notifications is a React and Tailwind CSS feedback component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

No packages beyond the project baseline

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Team Notifications

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>
  );
}

Similar components