[PULSE-55] In-app notification center, settings tab, and notifications page #28
@@ -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.
|
||||
|
||||
231
app/notifications/page.tsx
Normal file
231
app/notifications/page.tsx
Normal file
@@ -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 <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
}
|
||||
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" />
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user } = useAuth()
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
|
||||
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
|
||||
Go to workspace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back
|
||||
</Link>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" onClick={handleMarkAllRead}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">Notifications</h1>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
||||
<p>No notifications yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id}>
|
||||
{n.link_url ? (
|
||||
<Link
|
||||
href={n.link_url}
|
||||
onClick={() => 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' : ''}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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' : ''}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="pt-4 text-center">
|
||||
<Button variant="ghost" onClick={handleLoadMore} isLoading={loadingMore}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(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() {
|
||||
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
|
||||
)}
|
||||
{!loading && !error && (notifications?.length ?? 0) === 0 && (
|
||||
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
No notifications yet
|
||||
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm space-y-2">
|
||||
<p>No notifications yet</p>
|
||||
<p className="text-xs">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline" onClick={() => setOpen(false)}>
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (notifications?.length ?? 0) > 0 && (
|
||||
@@ -226,6 +249,24 @@ export default function NotificationCenter() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
|
||||
<Link
|
||||
href="/notifications"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-brand-orange hover:underline"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
<Link
|
||||
href="/org-settings?tab=notifications"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
Manage settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<BoxIcon className="w-5 h-5" />
|
||||
Billing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('notifications')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'notifications'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<BellIcon className="w-5 h-5" />
|
||||
Notifications
|
||||
</button>
|
||||
{(user?.role === 'owner' || user?.role === 'admin') && (
|
||||
<button
|
||||
onClick={() => handleTabChange('notifications')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'notifications'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<BellIcon className="w-5 h-5" />
|
||||
Notifications
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleTabChange('audit')}
|
||||
role="tab"
|
||||
|
||||
@@ -22,8 +22,18 @@ export interface ListNotificationsResponse {
|
||||
unread_count: number
|
||||
}
|
||||
|
||||
export async function listNotifications(): Promise<ListNotificationsResponse> {
|
||||
return apiRequest<ListNotificationsResponse>('/notifications')
|
||||
export interface ListNotificationsParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export async function listNotifications(params?: ListNotificationsParams): Promise<ListNotificationsResponse> {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.limit != null) q.set('limit', String(params.limit))
|
||||
if (params?.offset != null) q.set('offset', String(params.offset))
|
||||
const query = q.toString()
|
||||
const url = query ? `/notifications?${query}` : '/notifications'
|
||||
return apiRequest<ListNotificationsResponse>(url)
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -42,7 +42,7 @@
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -9260,6 +9260,8 @@
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.50",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"axios": "^1.13.2",
|
||||
"country-flag-icons": "^1.6.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
@@ -50,6 +50,6 @@
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
Actual API error is discarded
The catch block creates a
new Error('Failed to mark all as read')instead of passing the caught error togetAuthErrorMessage. SincegetAuthErrorMessagechecks for.statuson the error to return the appropriate message, this new plainErrorwill always yield'Something went wrong, please try again.'regardless of the actual failure reason (e.g. 401 session expired vs. 500 server error).Prompt To Fix With AI
Issue: The catch block was creating new Error('Failed to mark all as read') and passing it to getAuthErrorMessage, so the original API error was lost and users always saw a generic message even for auth-specific failures (e.g. 401).
Fix: Use the caught error: catch (err) and getAuthErrorMessage(err as Error) || 'Failed to mark all as read' so auth-related errors surface correctly and other errors still show a fallback message.
Why: getAuthErrorMessage relies on the real error (e.g. .status) to choose the message. Without the actual error, auth failures like expired sessions cannot be distinguished from generic server errors.