From c6373d5f2d12b6a1a7ee15723b9896ccb32917ee Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Feb 2026 11:55:08 +0100 Subject: [PATCH] feat: enhance notifications system with UX improvements, new settings management links, and audit log for notification preferences --- CHANGELOG.md | 3 + app/notifications/page.tsx | 231 ++++++++++++++++++ .../notifications/NotificationCenter.tsx | 49 +++- components/settings/OrganizationSettings.tsx | 35 ++- lib/api/notifications.ts | 14 +- package-lock.json | 4 +- package.json | 4 +- 7 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 app/notifications/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3115b..8716a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Notification settings.** New Notifications tab in organization settings lets owners and admins toggle billing and uptime notification categories. Disabling a category stops new notifications of that type from being created. - **In-app notification center.** Bell icon in the header with dropdown of recent notifications. Uptime monitor status changes (down, degraded, recovered) create in-app notifications with links to the uptime page. +- **Notifications UX improvements.** Bell dropdown links to "Manage settings" and "View all" notifications page. Empty state guides users to Organization Settings. Unread count polls every 90 seconds. Full notifications page at /notifications with pagination. +- **Notifications tab visibility.** Notifications tab in organization settings is hidden from members (owners and admins only). +- **Audit log for notification settings.** Changes to notification preferences are recorded in the organization audit log. - **Payment failed notifications.** When Stripe sends `invoice.payment_failed`, owners and admins receive an in-app notification with a link to update payment method. Members do not see billing notifications. - **Pageview limit notifications.** Owners and admins are notified when usage reaches 80%, 90%, or 100% of the plan limit (checked every 6 hours). - **Trial ending soon.** When a trial ends within 7 days, owners and admins receive a notification. Triggered by Stripe webhooks and a periodic checker. diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..679d1b8 --- /dev/null +++ b/app/notifications/page.tsx @@ -0,0 +1,231 @@ +'use client' + +/** + * @file Full notifications list page (View all). + */ + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useAuth } from '@/lib/auth/context' +import { + listNotifications, + markNotificationRead, + markAllNotificationsRead, + type Notification, +} from '@/lib/api/notifications' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' +import { AlertTriangleIcon, CheckCircleIcon, Button, ArrowLeftIcon } from '@ciphera-net/ui' +import { toast } from '@ciphera-net/ui' + +const PAGE_SIZE = 50 + +function formatTimeAgo(dateStr: string): string { + const d = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return d.toLocaleDateString() +} + +function getTypeIcon(type: string) { + if (type.includes('down') || type.includes('degraded') || type.startsWith('billing_')) { + return + } + return +} + +export default function NotificationsPage() { + const { user } = useAuth() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + + const fetchPage = async (pageOffset: number, append: boolean) => { + if (append) setLoadingMore(true) + else setLoading(true) + setError(null) + try { + const res = await listNotifications({ limit: PAGE_SIZE, offset: pageOffset }) + const list = Array.isArray(res?.notifications) ? res.notifications : [] + setNotifications((prev) => (append ? [...prev, ...list] : list)) + setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0) + setHasMore(list.length === PAGE_SIZE) + } catch (err) { + setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications') + setNotifications((prev) => (append ? prev : [])) + } finally { + setLoading(false) + setLoadingMore(false) + } + } + + useEffect(() => { + if (!user?.org_id) { + setLoading(false) + return + } + fetchPage(0, false) + }, [user?.org_id]) + + const handleLoadMore = () => { + const next = offset + PAGE_SIZE + setOffset(next) + fetchPage(next, true) + } + + const handleMarkRead = async (id: string) => { + try { + await markNotificationRead(id) + setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))) + setUnreadCount((c) => Math.max(0, c - 1)) + } catch { + // Ignore + } + } + + const handleMarkAllRead = async () => { + try { + await markAllNotificationsRead() + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + setUnreadCount(0) + toast.success('All notifications marked as read') + } catch { + toast.error(getAuthErrorMessage(new Error('Failed to mark all as read'))) + } + } + + const handleNotificationClick = (n: Notification) => { + if (!n.read) handleMarkRead(n.id) + } + + if (!user?.org_id) { + return ( +
+
+

Switch to an organization to view notifications.

+ + Go to workspace + +
+
+ ) + } + + return ( +
+
+
+ + + Back + + {unreadCount > 0 && ( + + )} +
+ +

Notifications

+

+ Manage which notifications you receive in{' '} + + Organization Settings → Notifications + +

+ + {loading ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : notifications.length === 0 ? ( +
+

No notifications yet

+

+ Manage which notifications you receive in{' '} + + Organization Settings → Notifications + +

+
+ ) : ( +
+ {notifications.map((n) => ( +
+ {n.link_url ? ( + handleNotificationClick(n)} + className={`block p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} + > +
+ {getTypeIcon(n.type)} +
+

+ {n.title} +

+ {n.body && ( +

{n.body}

+ )} +

+ {formatTimeAgo(n.created_at)} +

+
+
+ + ) : ( +
handleNotificationClick(n)} + onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)} + className={`block p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} + > +
+ {getTypeIcon(n.type)} +
+

+ {n.title} +

+ {n.body && ( +

{n.body}

+ )} +

+ {formatTimeAgo(n.created_at)} +

+
+
+
+ )} +
+ ))} + {hasMore && ( +
+ +
+ )} +
+ )} +
+
+ ) +} diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index c79b58a..6a6e9e1 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -8,7 +8,7 @@ import { useEffect, useState, useRef } from 'react' import Link from 'next/link' import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications' import { getAuthErrorMessage } from '@/lib/utils/authErrors' -import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui' +import { AlertTriangleIcon, CheckCircleIcon, SettingsIcon } from '@ciphera-net/ui' // * Bell icon (simple SVG, no extra deps) function BellIcon({ className }: { className?: string }) { @@ -53,6 +53,7 @@ function getTypeIcon(type: string) { } const LOADING_DELAY_MS = 250 +const POLL_INTERVAL_MS = 90_000 export default function NotificationCenter() { const [open, setOpen] = useState(false) @@ -62,11 +63,20 @@ export default function NotificationCenter() { const [error, setError] = useState(null) const dropdownRef = useRef(null) + const fetchUnreadCount = async () => { + try { + const res = await listNotifications({ limit: 1 }) + setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0) + } catch { + // Ignore polling errors + } + } + const fetchNotifications = async () => { setError(null) const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY_MS) try { - const res = await listNotifications() + const res = await listNotifications({}) setNotifications(Array.isArray(res?.notifications) ? res.notifications : []) setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0) } catch (err) { @@ -85,6 +95,13 @@ export default function NotificationCenter() { } }, [open]) + // * Poll unread count in background (when authenticated) + useEffect(() => { + fetchUnreadCount() + const id = setInterval(fetchUnreadCount, POLL_INTERVAL_MS) + return () => clearInterval(id) + }, []) + // * Close dropdown when clicking outside useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -164,8 +181,14 @@ export default function NotificationCenter() {
{error}
)} {!loading && !error && (notifications?.length ?? 0) === 0 && ( -
- No notifications yet +
+

No notifications yet

+

+ Manage which notifications you receive in{' '} + setOpen(false)}> + Organization Settings → Notifications + +

)} {!loading && !error && (notifications?.length ?? 0) > 0 && ( @@ -226,6 +249,24 @@ export default function NotificationCenter() { )}
+ +
+ setOpen(false)} + className="text-sm text-brand-orange hover:underline" + > + View all + + setOpen(false)} + className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors" + > + + Manage settings + +
)} diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index da536cc..3e72400 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -286,6 +286,13 @@ export default function OrganizationSettings() { } }, [activeTab, currentOrgId, loadNotificationSettings]) + // * Redirect members away from Notifications tab (owners/admins only) + useEffect(() => { + if (activeTab === 'notifications' && user?.role === 'member') { + handleTabChange('general') + } + }, [activeTab, user?.role]) + // If no org ID, we are in personal organization context, so don't show org settings if (!currentOrgId) { return ( @@ -498,19 +505,21 @@ export default function OrganizationSettings() { Billing - + {(user?.role === 'owner' || user?.role === 'admin') && ( + + )}