[PULSE-55] In-app notification center, settings tab, and notifications page #28
19
CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to Pulse (frontend and product) are documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.0-alpha] - 2026-02-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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. 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.
|
||||||
|
- **Subscription canceled.** When a subscription is canceled, owners and admins are notified with a link to billing.
|
||||||
|
|
||||||
## [0.5.1-alpha] - 2026-02-12
|
## [0.5.1-alpha] - 2026-02-12
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -51,7 +67,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...HEAD
|
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...HEAD
|
||||||
|
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
|
||||||
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha
|
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha
|
||||||
[0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha
|
[0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha
|
||||||
[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha
|
[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { Header, GridIcon } from '@ciphera-net/ui'
|
import { Header } from '@ciphera-net/ui'
|
||||||
|
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -21,7 +22,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
getUserOrganizations()
|
getUserOrganizations()
|
||||||
.then((organizations) => setOrgs(organizations))
|
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
|
||||||
.catch(err => console.error('Failed to fetch orgs for header', err))
|
.catch(err => console.error('Failed to fetch orgs for header', err))
|
||||||
}
|
}
|
||||||
}, [auth.user])
|
}, [auth.user])
|
||||||
@@ -63,6 +64,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
showSecurity={false}
|
showSecurity={false}
|
||||||
showPricing={true}
|
showPricing={true}
|
||||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||||
|
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||||
customNavItems={
|
customNavItems={
|
||||||
<>
|
<>
|
||||||
{!auth.user && (
|
{!auth.user && (
|
||||||
|
|||||||
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 (err) {
|
||||||
|
toast.error(getAuthErrorMessage(err as 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
268
components/notifications/NotificationCenter.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file Notification center: bell icon with dropdown of recent notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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, SettingsIcon } from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
// * Bell icon (simple SVG, no extra deps)
|
||||||
|
function BellIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
Duplicated
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! Prompt To Fix With AI**Duplicated `formatTimeAgo` and `getTypeIcon` utilities**
`formatTimeAgo` and `getTypeIcon` are identical copies between this file and `app/notifications/page.tsx`. Consider extracting them into a shared utility (e.g., `lib/utils/notifications.ts`) to avoid maintaining the same logic in two places.
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/notifications/NotificationCenter.tsx
Line: 34:46
Comment:
**Duplicated `formatTimeAgo` and `getTypeIcon` utilities**
`formatTimeAgo` and `getTypeIcon` are identical copies between this file and `app/notifications/page.tsx`. Consider extracting them into a shared utility (e.g., `lib/utils/notifications.ts`) to avoid maintaining the same logic in two places.
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Issue: formatTimeAgo and getTypeIcon were duplicated in NotificationCenter.tsx and app/notifications/page.tsx, so both need updates when the behavior changes. Issue: formatTimeAgo and getTypeIcon were duplicated in NotificationCenter.tsx and app/notifications/page.tsx, so both need updates when the behavior changes.
Fix: Moved both helpers to lib/utils/notifications.tsx and switched both consumers to import from the shared utility.
Why: Centralizes logic in one place so future changes are consistent across the notification center and notifications page.
|
|||||||
|
|
||||||
|
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" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_DELAY_MS = 250
|
||||||
|
const POLL_INTERVAL_MS = 90_000
|
||||||
|
|
||||||
|
export default function NotificationCenter() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
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({})
|
||||||
|
setNotifications(Array.isArray(res?.notifications) ? res.notifications : [])
|
||||||
|
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications')
|
||||||
|
setNotifications([])
|
||||||
|
setUnreadCount(0)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(loadingTimer)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchNotifications()
|
||||||
|
}
|
||||||
|
}, [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) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
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; user can retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
||||||
|
setUnreadCount(0)
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (n: Notification) => {
|
||||||
|
if (!n.read) {
|
||||||
|
handleMarkRead(n.id)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||||
|
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
|
||||||
|
>
|
||||||
|
<BellIcon />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
className="text-sm text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{!loading && !error && (notifications?.length ?? 0) > 0 && (
|
||||||
|
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
|
{(notifications ?? []).map((n) => (
|
||||||
|
<li key={n.id}>
|
||||||
|
{n.link_url ? (
|
||||||
|
<Link
|
||||||
|
href={n.link_url}
|
||||||
|
onClick={() => handleNotificationClick(n)}
|
||||||
|
className={`block px-4 py-3 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 line-clamp-2">
|
||||||
|
{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 px-4 py-3 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 line-clamp-2">
|
||||||
|
{n.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
{formatTimeAgo(n.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
||||||
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans'
|
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans'
|
||||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||||
|
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
@@ -33,8 +34,18 @@ import {
|
|||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
LayoutDashboardIcon
|
LayoutDashboardIcon,
|
||||||
} from '@ciphera-net/ui'
|
} from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
// * Bell icon for notifications tab
|
||||||
|
function BellIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { Button, Input } from '@ciphera-net/ui'
|
import { Button, Input } from '@ciphera-net/ui'
|
||||||
|
|
||||||
@@ -43,13 +54,13 @@ export default function OrganizationSettings() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
// Initialize from URL, default to 'general'
|
// Initialize from URL, default to 'general'
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'audit'>(() => {
|
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'notifications' | 'audit'>(() => {
|
||||||
const tab = searchParams.get('tab')
|
const tab = searchParams.get('tab')
|
||||||
return (tab === 'billing' || tab === 'members' || tab === 'audit') ? tab : 'general'
|
return (tab === 'billing' || tab === 'members' || tab === 'notifications' || tab === 'audit') ? tab : 'general'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync URL with state without triggering navigation/reload
|
// Sync URL with state without triggering navigation/reload
|
||||||
const handleTabChange = (tab: 'general' | 'members' | 'billing' | 'audit') => {
|
const handleTabChange = (tab: 'general' | 'members' | 'billing' | 'notifications' | 'audit') => {
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('tab', tab)
|
url.searchParams.set('tab', tab)
|
||||||
@@ -107,6 +118,12 @@ export default function OrganizationSettings() {
|
|||||||
const [auditStartDate, setAuditStartDate] = useState('')
|
const [auditStartDate, setAuditStartDate] = useState('')
|
||||||
const [auditEndDate, setAuditEndDate] = useState('')
|
const [auditEndDate, setAuditEndDate] = useState('')
|
||||||
|
|
||||||
|
// Notification settings state
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState<Record<string, boolean>>({})
|
||||||
|
const [notificationCategories, setNotificationCategories] = useState<{ id: string; label: string; description: string }[]>([])
|
||||||
|
const [isLoadingNotificationSettings, setIsLoadingNotificationSettings] = useState(false)
|
||||||
|
const [isSavingNotificationSettings, setIsSavingNotificationSettings] = useState(false)
|
||||||
|
|
||||||
// Refs for filters to keep loadAudit stable and avoid rapid re-renders
|
// Refs for filters to keep loadAudit stable and avoid rapid re-renders
|
||||||
const filtersRef = useRef({
|
const filtersRef = useRef({
|
||||||
action: auditActionFilter,
|
action: auditActionFilter,
|
||||||
@@ -248,6 +265,34 @@ export default function OrganizationSettings() {
|
|||||||
}
|
}
|
||||||
}, [activeTab, currentOrgId, loadAudit, auditFetchTrigger])
|
}, [activeTab, currentOrgId, loadAudit, auditFetchTrigger])
|
||||||
|
|
||||||
|
const loadNotificationSettings = useCallback(async () => {
|
||||||
|
if (!currentOrgId) return
|
||||||
|
setIsLoadingNotificationSettings(true)
|
||||||
|
try {
|
||||||
|
const res = await getNotificationSettings()
|
||||||
|
setNotificationSettings(res.settings || {})
|
||||||
|
setNotificationCategories(res.categories || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load notification settings', error)
|
||||||
|
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load notification settings')
|
||||||
|
} finally {
|
||||||
|
setIsLoadingNotificationSettings(false)
|
||||||
|
}
|
||||||
|
}, [currentOrgId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'notifications' && currentOrgId) {
|
||||||
|
loadNotificationSettings()
|
||||||
|
}
|
||||||
|
}, [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 no org ID, we are in personal organization context, so don't show org settings
|
||||||
if (!currentOrgId) {
|
if (!currentOrgId) {
|
||||||
return (
|
return (
|
||||||
@@ -460,6 +505,21 @@ export default function OrganizationSettings() {
|
|||||||
<BoxIcon className="w-5 h-5" />
|
<BoxIcon className="w-5 h-5" />
|
||||||
Billing
|
Billing
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleTabChange('audit')}
|
onClick={() => handleTabChange('audit')}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -919,6 +979,73 @@ export default function OrganizationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Notification Settings</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
|
||||||
|
Choose which notification types you want to receive. These apply to the notification center for owners and admins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingNotificationSettings ? (
|
||||||
|
<div className="flex items-center 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>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Notification categories</h3>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||||
|
{notificationCategories.map((cat) => (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-white">{cat.label}</p>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{cat.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notificationSettings[cat.id] !== false}
|
||||||
|
aria-label={`${notificationSettings[cat.id] !== false ? 'Disable' : 'Enable'} ${cat.label} notifications`}
|
||||||
|
onClick={() => {
|
||||||
|
const prev = { ...notificationSettings }
|
||||||
|
const next = { ...notificationSettings, [cat.id]: notificationSettings[cat.id] === false }
|
||||||
|
setNotificationSettings(next)
|
||||||
|
setIsSavingNotificationSettings(true)
|
||||||
|
updateNotificationSettings(next)
|
||||||
|
.then(() => {
|
||||||
|
toast.success('Notification settings updated')
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(getAuthErrorMessage(err) || 'Failed to update settings')
|
||||||
|
setNotificationSettings(prev)
|
||||||
|
})
|
||||||
|
.finally(() => setIsSavingNotificationSettings(false))
|
||||||
|
}}
|
||||||
|
disabled={isSavingNotificationSettings}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
notificationSettings[cat.id] !== false ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
notificationSettings[cat.id] !== false ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'audit' && (
|
{activeTab === 'audit' && (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
22
lib/api/notification-settings.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @file Notification settings API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiRequest from './client'
|
||||||
|
|
||||||
|
export interface NotificationSettingsResponse {
|
||||||
|
settings: Record<string, boolean>
|
||||||
|
categories: { id: string; label: string; description: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationSettings(): Promise<NotificationSettingsResponse> {
|
||||||
|
return apiRequest<NotificationSettingsResponse>('/notification-settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotificationSettings(settings: Record<string, boolean>): Promise<void> {
|
||||||
|
return apiRequest<void>('/notification-settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ settings }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
49
lib/api/notifications.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @file Notifications API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiRequest from './client'
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string
|
||||||
|
organization_id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
read: boolean
|
||||||
|
link_url?: string
|
||||||
|
link_label?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListNotificationsResponse {
|
||||||
|
notifications: Notification[]
|
||||||
|
unread_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return apiRequest<void>(`/notifications/${id}/read`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
return apiRequest<void>('/notifications/mark-all-read', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
724
package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.5.0-alpha",
|
"version": "0.6.0-alpha",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.0.49",
|
"@ciphera-net/ui": "^0.0.50",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
@@ -49,6 +50,6 @@
|
|||||||
"eslint-config-next": "^16.1.1",
|
"eslint-config-next": "^16.1.1",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.