nxtgauge-frontend-solid/src/components/NotificationBell.tsx
2026-04-29 11:51:37 +02:00

213 lines
7.9 KiB
TypeScript

import { createSignal, createEffect, onCleanup, Show } from "solid-js";
import { api } from "~/lib/api";
const ORANGE = "#FF5E13";
const NAVY = "#0D0D2A";
export default function NotificationBell() {
const [unreadCount, setUnreadCount] = createSignal(0);
const [showDropdown, setShowDropdown] = createSignal(false);
const [notifications, setNotifications] = createSignal<any[]>([]);
// Poll for unread count every 30 seconds
createEffect(() => {
const fetchUnreadCount = async () => {
try {
const res = await api.get("/me/notifications/unread-count");
setUnreadCount(res.data?.unread_count || 0);
} catch (e) {
// Silently fail
}
};
// Initial fetch
fetchUnreadCount();
// Set up polling interval
const interval = setInterval(fetchUnreadCount, 30000);
onCleanup(() => clearInterval(interval));
});
// Fetch notifications when dropdown opens
const fetchNotifications = async () => {
try {
const res = await api.get("/me/notifications?limit=5");
setNotifications(res.data?.data || []);
} catch (e) {
setNotifications([]);
}
};
const toggleDropdown = () => {
const newState = !showDropdown();
setShowDropdown(newState);
if (newState) {
fetchNotifications();
}
};
const markAsRead = async (id: string) => {
try {
await api.patch(`/me/notifications/${id}/read`);
// Update local state
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch (e) {
console.error("Failed to mark as read", e);
}
};
const markAllAsRead = async () => {
try {
await api.patch("/me/notifications/read-all");
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
setUnreadCount(0);
} catch (e) {
console.error("Failed to mark all as read", e);
}
};
const formatTime = (date: string) => {
const now = new Date();
const notifDate = new Date(date);
const diff = now.getTime() - notifDate.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`;
};
return (
<div class="relative">
<button
onClick={toggleDropdown}
class="relative h-8 w-8 rounded-full border border-[#E5E7EB] bg-white text-[#6B7280] hover:text-[#111827] hover:border-[#D1D5DB] transition-colors flex items-center justify-center"
aria-label="Notifications"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
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>
{/* Unread Badge */}
<Show when={unreadCount() > 0}>
<span
class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 inline-flex items-center justify-center text-[10px] font-bold leading-none text-white rounded-full"
style={{ background: ORANGE }}
>
{unreadCount() > 99 ? "99+" : unreadCount()}
</span>
</Show>
</button>
{/* Dropdown */}
<Show when={showDropdown()}>
<>
{/* Backdrop */}
<div class="fixed inset-0 z-40" onClick={() => setShowDropdown(false)} />
{/* Dropdown Panel */}
<div class="absolute right-0 mt-2 w-[360px] max-w-[calc(100vw-24px)] bg-white rounded-2xl border border-[#E5E7EB] shadow-[0_10px_30px_rgba(2,6,23,0.08)] z-50 overflow-hidden">
<div class="flex justify-between items-center px-4 py-3 border-b border-[#E5E7EB] bg-[#FCFCFD]">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold" style={{ color: NAVY }}>Notifications</h3>
<Show when={unreadCount() > 0}>
<span class="text-[11px] font-semibold px-2 py-0.5 rounded-full bg-[#FFF3EE] text-[#C2410C]">
{unreadCount()} unread
</span>
</Show>
</div>
<Show when={unreadCount() > 0}>
<button
onClick={markAllAsRead}
class="text-xs font-semibold hover:opacity-90"
style={{ color: ORANGE }}
>
Mark all read
</button>
</Show>
</div>
<div class="max-h-[420px] overflow-y-auto">
<Show
when={notifications().length > 0}
fallback={
<div class="px-6 py-10 text-center">
<div class="mx-auto mb-3 w-10 h-10 rounded-full bg-[#F3F4F6] flex items-center justify-center text-[#9CA3AF]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={1.8} 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>
</div>
<p class="text-sm font-semibold text-[#374151]">No notifications yet</p>
<p class="text-xs text-[#6B7280] mt-1">We will show updates here when they arrive.</p>
</div>
}
>
{notifications().map((notification) => (
<div
class={`px-4 py-3 border-b border-[#F1F5F9] hover:bg-[#F8FAFC] cursor-pointer transition-colors ${
!notification.is_read ? "bg-[#FFF7ED]" : "bg-white"
}`}
onClick={() => {
if (!notification.is_read) {
markAsRead(notification.id);
}
}}
>
<div class="flex items-start gap-3">
{/* Unread Dot */}
<div class="mt-1.5 shrink-0">
<div
class={`w-2 h-2 rounded-full ${
!notification.is_read ? "bg-[#F97316]" : "bg-transparent"
}`}
/>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-[#111827] line-clamp-1">
{notification.title}
</p>
<p class="text-sm text-[#4B5563] line-clamp-2 mt-0.5">{notification.body}</p>
<p class="text-[11px] text-[#9CA3AF] mt-1.5">
{formatTime(notification.created_at)}
</p>
</div>
</div>
</div>
))}
</Show>
</div>
<div class="p-3 border-t border-[#E5E7EB] bg-[#FCFCFD]">
<a
href="/dashboard/notifications"
class="block text-center text-sm font-semibold hover:opacity-90"
style={{ color: ORANGE }}
onClick={() => setShowDropdown(false)}
>
View all notifications
</a>
</div>
</div>
</>
</Show>
</div>
);
}