219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
// In-App Notification Center - Bell icon with dropdown panel
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
|
interface Notification {
|
|
id: string;
|
|
title: string;
|
|
message: string;
|
|
type: "info" | "success" | "warning" | "error";
|
|
read: boolean;
|
|
created_at: string;
|
|
link?: string;
|
|
}
|
|
|
|
interface NotificationCenterProps {
|
|
brandId?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
export default function NotificationCenter({ brandId, userId }: NotificationCenterProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
|
|
|
// Define fetchNotifications before useEffect
|
|
const fetchNotifications = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setNotifications(data.notifications || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch notifications:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [brandId]);
|
|
|
|
// Fetch notifications when opened — handled in the click handler so
|
|
// we don't need a useEffect to watch `isOpen` and re-fetch on every
|
|
// open transition. See handleToggleOpen below.
|
|
|
|
// Close on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const markAsRead = async (id: string) => {
|
|
try {
|
|
await fetch("/api/notifications", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id, read: true }),
|
|
});
|
|
|
|
setNotifications((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to mark as read:", error);
|
|
}
|
|
};
|
|
|
|
const markAllAsRead = async () => {
|
|
try {
|
|
await fetch("/api/notifications/mark-all-read", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ brand_id: brandId }),
|
|
});
|
|
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
} catch (error) {
|
|
console.error("Failed to mark all as read:", error);
|
|
}
|
|
};
|
|
|
|
const formatTime = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (minutes < 1) return "Just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${days}d ago`;
|
|
};
|
|
|
|
const getTypeStyles = (type: string) => {
|
|
switch (type) {
|
|
case "success":
|
|
return "bg-emerald-100 text-emerald-600";
|
|
case "warning":
|
|
return "bg-amber-100 text-amber-600";
|
|
case "error":
|
|
return "bg-red-100 text-red-600";
|
|
default:
|
|
return "bg-blue-100 text-blue-600";
|
|
}
|
|
};
|
|
|
|
const handleToggleOpen = useCallback(async () => {
|
|
const next = !isOpen;
|
|
setIsOpen(next);
|
|
if (next && brandId) {
|
|
await fetchNotifications();
|
|
}
|
|
}, [isOpen, brandId, fetchNotifications]);
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
{/* Bell Icon Button */}
|
|
<button type="button"
|
|
onClick={handleToggleOpen}
|
|
className="relative p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
|
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ""}`}
|
|
>
|
|
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
</svg>
|
|
{unreadCount > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Dropdown Panel */}
|
|
{isOpen && (
|
|
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
|
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
|
{unreadCount > 0 && (
|
|
<button type="button"
|
|
onClick={markAllAsRead}
|
|
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
|
>
|
|
Mark all as read
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notifications List */}
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="p-8 text-center">
|
|
<div className="w-6 h-6 border-2 border-gray-300 border-t-[#1a4d2e] rounded-full animate-spin mx-auto" />
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="p-8 text-center">
|
|
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
|
</svg>
|
|
<p className="text-sm text-gray-500">No notifications yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-50">
|
|
{notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
if (!notification.read) markAsRead(notification.id);
|
|
if (notification.link) window.location.href = notification.link;
|
|
}
|
|
}}
|
|
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
|
!notification.read ? "bg-emerald-50/50" : ""
|
|
}`}
|
|
onClick={() => {
|
|
if (!notification.read) markAsRead(notification.id);
|
|
if (notification.link) window.location.href = notification.link;
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span className={`shrink-0 w-2 h-2 mt-2 rounded-full ${getTypeStyles(notification.type)} ${notification.read ? "opacity-30" : ""}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-gray-900 text-sm">{notification.title}</p>
|
|
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{notification.message}</p>
|
|
<p className="text-xs text-gray-400 mt-2">{formatTime(notification.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t border-gray-100 px-4 py-3">
|
|
<Link href="/admin/notifications" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium">
|
|
View all notifications
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |