213 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|