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