diff --git a/invofi/apps/frontend/src/app/settings/page.tsx b/invofi/apps/frontend/src/app/settings/page.tsx index 328571797..c1f292bfb 100644 --- a/invofi/apps/frontend/src/app/settings/page.tsx +++ b/invofi/apps/frontend/src/app/settings/page.tsx @@ -9,6 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { PageHeader } from '@/components/common/PageHeader'; import { useToast } from '@/components/ui/use-toast'; import { createClient } from '@/utils/supabase/client'; +import { NotificationPreferencesPanel } from '@/components/notifications/NotificationPreferencesPanel'; + export default function SettingsPage() { const router = useRouter(); @@ -63,6 +65,15 @@ export default function SettingsPage() { + + + Notifications + + + + + + Account @@ -77,3 +88,4 @@ export default function SettingsPage() { ); } + diff --git a/invofi/apps/frontend/src/components/NavbarEventIndicator.tsx b/invofi/apps/frontend/src/components/NavbarEventIndicator.tsx index 3acad291e..c99c3f35a 100644 --- a/invofi/apps/frontend/src/components/NavbarEventIndicator.tsx +++ b/invofi/apps/frontend/src/components/NavbarEventIndicator.tsx @@ -2,12 +2,20 @@ import { useEventSubscription } from '@/hooks/useEventSubscription'; import { ConnectionIndicator } from '@/components/ConnectionIndicator'; +import { NotificationBell } from '@/components/notifications/NotificationBell'; /** - * Client component that renders the connection status indicator in the navbar. - * Extracted to keep Navbar.tsx clean and the event subscription isolated. + * Client component that renders the connection status indicator and notification + * bell in the navbar. Extracted to keep Navbar.tsx clean and the event + * subscription isolated. */ export function NavbarEventIndicator() { const { status, eventCount } = useEventSubscription(); - return ; + return ( +
+ + +
+ ); } + diff --git a/invofi/apps/frontend/src/components/layout/Providers.tsx b/invofi/apps/frontend/src/components/layout/Providers.tsx index fce520b6e..c75990242 100644 --- a/invofi/apps/frontend/src/components/layout/Providers.tsx +++ b/invofi/apps/frontend/src/components/layout/Providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; import { WalletProvider } from '@/components/auth/WalletProvider'; +import { NotificationProvider } from '@/components/notifications/NotificationProvider'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -14,7 +15,10 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } + diff --git a/invofi/apps/frontend/src/components/notifications/NotificationBell.tsx b/invofi/apps/frontend/src/components/notifications/NotificationBell.tsx new file mode 100644 index 000000000..9d3bd1507 --- /dev/null +++ b/invofi/apps/frontend/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,55 @@ +'use client'; + +// ── NotificationBell (issue #255) ───────────────────────────────────────────── +// Bell icon with unread-count badge; toggles the NotificationPanel on click. + +import { useState } from 'react'; +import { Bell } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useNotifications } from './NotificationProvider'; +import { NotificationPanel } from './NotificationPanel'; + +export function NotificationBell() { + const { unreadCount } = useNotifications(); + const [open, setOpen] = useState(false); + + const toggle = () => setOpen((v) => !v); + const close = () => setOpen(false); + + return ( +
+ + + +
+ ); +} diff --git a/invofi/apps/frontend/src/components/notifications/NotificationItem.tsx b/invofi/apps/frontend/src/components/notifications/NotificationItem.tsx new file mode 100644 index 000000000..9c8472a32 --- /dev/null +++ b/invofi/apps/frontend/src/components/notifications/NotificationItem.tsx @@ -0,0 +1,118 @@ +'use client'; + +// ── NotificationItem (issue #255) ───────────────────────────────────────────── +// A single row in the notification panel. + +import { Bell, CheckCircle, AlertTriangle, TrendingUp, Info, X } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { cn } from '@/lib/utils'; +import type { AppNotification, NotificationCategory } from '@/types'; + +const CATEGORY_ICON: Record = { + offer: TrendingUp, + repayment: CheckCircle, + alert: AlertTriangle, + info: Info, +}; + +const CATEGORY_COLOR: Record = { + offer: 'text-blue-500 bg-blue-50 dark:bg-blue-950/40', + repayment: 'text-green-500 bg-green-50 dark:bg-green-950/40', + alert: 'text-amber-500 bg-amber-50 dark:bg-amber-950/40', + info: 'text-muted-foreground bg-muted', +}; + +interface NotificationItemProps { + notification: AppNotification; + onMarkRead: (id: string) => void; + onDismiss: (id: string) => void; + /** Optional click handler for navigating to the relevant invoice/offer. */ + onClick?: (notification: AppNotification) => void; +} + +export function NotificationItem({ + notification, + onMarkRead, + onDismiss, + onClick, +}: NotificationItemProps) { + const Icon = CATEGORY_ICON[notification.category] ?? Bell; + const colorClass = CATEGORY_COLOR[notification.category]; + + const handleClick = () => { + if (!notification.read) onMarkRead(notification.id); + onClick?.(notification); + }; + + const handleDismiss = (e: React.MouseEvent) => { + e.stopPropagation(); + onDismiss(notification.id); + }; + + const timeAgo = (() => { + try { + return formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true }); + } catch { + return ''; + } + })(); + + return ( +
+ {/* Category icon */} + + + + + {/* Content */} +
+

+ {notification.title} +

+

{notification.body}

+ {timeAgo && ( +

{timeAgo}

+ )} +
+ + {/* Unread dot */} + {!notification.read && ( + + )} + + {/* Dismiss button (visible on hover) */} + +
+ ); +} diff --git a/invofi/apps/frontend/src/components/notifications/NotificationPanel.tsx b/invofi/apps/frontend/src/components/notifications/NotificationPanel.tsx new file mode 100644 index 000000000..3af3d7705 --- /dev/null +++ b/invofi/apps/frontend/src/components/notifications/NotificationPanel.tsx @@ -0,0 +1,188 @@ +'use client'; + +// ── NotificationPanel (issue #255) ─────────────────────────────────────────── +// Slide-in panel that shows categorised notifications. +// Uses existing Radix Tabs and the project's design system — no new deps. + +import { useRouter } from 'next/navigation'; +import { X, CheckCheck, Trash2, ChevronDown, BellOff } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { NotificationItem } from './NotificationItem'; +import { useNotifications } from './NotificationProvider'; +import type { AppNotification, NotificationCategory } from '@/types'; +import { cn } from '@/lib/utils'; + +interface NotificationPanelProps { + open: boolean; + onClose: () => void; +} + +function EmptyState({ label }: { label: string }) { + return ( +
+ +

{label}

+
+ ); +} + +export function NotificationPanel({ open, onClose }: NotificationPanelProps) { + const router = useRouter(); + const { + visibleNotifications, + notificationsByCategory, + unreadCount, + hasMore, + markRead, + markAllRead, + dismiss, + clearAll, + loadMore, + } = useNotifications(); + + const handleNotificationClick = (notification: AppNotification) => { + if (notification.subjectId) { + router.push(`/invoices/${notification.subjectId}`); + onClose(); + } + }; + + const renderList = (items: AppNotification[]) => { + if (items.length === 0) { + return ; + } + return ( +
+ {items.map((n) => ( + + ))} +
+ ); + }; + + const categories: Array<{ value: NotificationCategory | 'all'; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'offer', label: 'Offers' }, + { value: 'repayment', label: 'Repayments' }, + { value: 'alert', label: 'Alerts' }, + ]; + + return ( + <> + {/* Backdrop */} + {open && ( +
+ )} + + {/* Panel */} +
+ {/* Header */} +
+
+

Notifications

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+
+ {unreadCount > 0 && ( + + )} + {visibleNotifications.length > 0 && ( + + )} + +
+
+ + {/* Tabs */} + + + {categories.map(({ value, label }) => ( + + {label} + + ))} + + + {/* Scrollable body */} +
+ + {renderList(visibleNotifications)} + + + {categories.slice(1).map(({ value }) => ( + + {renderList(notificationsByCategory(value as NotificationCategory))} + + ))} +
+
+ + {/* Load more */} + {hasMore && ( +
+ +
+ )} +
+ + ); +} diff --git a/invofi/apps/frontend/src/components/notifications/NotificationPreferencesPanel.tsx b/invofi/apps/frontend/src/components/notifications/NotificationPreferencesPanel.tsx new file mode 100644 index 000000000..86c11f663 --- /dev/null +++ b/invofi/apps/frontend/src/components/notifications/NotificationPreferencesPanel.tsx @@ -0,0 +1,162 @@ +'use client'; + +// ── NotificationPreferencesPanel (issue #255) ───────────────────────────────── +// Per-event-type opt-in toggles + browser notification permission toggle. +// Rendered in the /settings page. + +import { useCallback, useEffect, useState } from 'react'; +import { + requestBrowserNotificationPermission, + getBrowserNotificationPermission, + isBrowserNotificationSupported, +} from '@/lib/notifications/browserNotifications'; +import { useNotifications } from '@/components/notifications/NotificationProvider'; +import type { NotificationPreferences } from '@/types'; + +interface ToggleRowProps { + id: string; + label: string; + description: string; + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; +} + +function ToggleRow({ id, label, description, checked, disabled = false, onChange }: ToggleRowProps) { + return ( +
+
+ +

{description}

+
+ +
+ ); +} + +const PREF_ROWS: Array<{ + key: keyof Omit; + label: string; + description: string; +}> = [ + { + key: 'offer_new', + label: 'New offers', + description: 'Notify me when a lender places an offer on one of my invoices.', + }, + { + key: 'offer_accepted', + label: 'Offer accepted', + description: 'Notify me when my financing offer is accepted by an originator.', + }, + { + key: 'offer_rejected', + label: 'Offer rejected / withdrawn', + description: 'Notify me when an offer is rejected or withdrawn.', + }, + { + key: 'invoice_overdue', + label: 'Overdue & default alerts', + description: 'Notify me when an invoice is marked overdue or defaults.', + }, + { + key: 'repayment', + label: 'Repayment confirmed', + description: 'Notify me when a repayment is confirmed on-chain.', + }, + { + key: 'dispute', + label: 'Disputes', + description: 'Notify me when a dispute is raised or resolved on one of my invoices.', + }, +]; + +export function NotificationPreferencesPanel() { + const { preferences, setPreferences } = useNotifications(); + const [browserPermission, setBrowserPermission] = useState(getBrowserNotificationPermission()); + const [requestingPermission, setRequestingPermission] = useState(false); + const supported = isBrowserNotificationSupported(); + + // Sync permission state in case the user changed it externally. + useEffect(() => { + setBrowserPermission(getBrowserNotificationPermission()); + }, [preferences.browserNotifications]); + + const handleBrowserToggle = useCallback(async (on: boolean) => { + if (on && browserPermission !== 'granted') { + setRequestingPermission(true); + const result = await requestBrowserNotificationPermission(); + setBrowserPermission(result); + setRequestingPermission(false); + if (result !== 'granted') { + // User denied — don't enable the preference. + return; + } + } + setPreferences({ browserNotifications: on }); + }, [browserPermission, setPreferences]); + + return ( +
+

+ Notification preferences +

+

+ Choose which events trigger in-app toasts and, optionally, OS-level alerts. +

+ +
+ {PREF_ROWS.map(({ key, label, description }) => ( + setPreferences({ [key]: v })} + /> + ))} + + {/* Browser notifications separator */} +
+

+ Desktop alerts +

+ + {requestingPermission && ( +

+ Waiting for permission… +

+ )} +
+
+
+ ); +} diff --git a/invofi/apps/frontend/src/components/notifications/NotificationProvider.tsx b/invofi/apps/frontend/src/components/notifications/NotificationProvider.tsx new file mode 100644 index 000000000..fd78ad7b6 --- /dev/null +++ b/invofi/apps/frontend/src/components/notifications/NotificationProvider.tsx @@ -0,0 +1,224 @@ +'use client'; + +// ── NotificationProvider (issue #255) ──────────────────────────────────────── +// React context provider that: +// 1. Manages notification state via useReducer + notificationReducer. +// 2. Subscribes to Soroban contract events via useEventSubscription. +// 3. Converts each incoming ProtocolEvent to an AppNotification via eventMap. +// 4. Fires an in-app toast and (optionally) a browser Notification. +// 5. Persists user preferences to localStorage. +// +// Consume via the useNotifications() hook exported at the bottom. + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, +} from 'react'; +import { useEventSubscription } from '@/hooks/useEventSubscription'; +import { toast } from '@/components/ui/use-toast'; +import { + notificationReducer, + buildInitialState, + selectVisible, + selectByCategory, + PAGE_SIZE, + type NotificationState, + type NotificationAction, +} from '@/lib/notifications/store'; +import { mapEventToNotification } from '@/lib/notifications/eventMap'; +import { + sendBrowserNotification, + getBrowserNotificationPermission, +} from '@/lib/notifications/browserNotifications'; +import type { + AppNotification, + NotificationCategory, + NotificationPreferences, +} from '@/types'; + +// ── Preferences persistence ─────────────────────────────────────────────────── + +const PREFS_KEY = 'invofi:notification-preferences'; + +function loadSavedPreferences(): Partial { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(PREFS_KEY); + return raw ? (JSON.parse(raw) as Partial) : {}; + } catch { + return {}; + } +} + +function savePreferences(prefs: NotificationPreferences): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + } catch { + // Private mode or quota — best-effort. + } +} + +// ── Context value shape ─────────────────────────────────────────────────────── + +export interface NotificationsContextValue { + /** All notifications visible within the current page (paginated). */ + visibleNotifications: AppNotification[]; + /** Notifications in a specific category (for tab rendering). */ + notificationsByCategory: (category: NotificationCategory) => AppNotification[]; + /** Total unread count (for the bell badge). */ + unreadCount: number; + /** Whether there are more notifications to load. */ + hasMore: boolean; + /** Current notification preferences. */ + preferences: NotificationPreferences; + /** Mark a single notification as read. */ + markRead: (id: string) => void; + /** Mark all notifications as read. */ + markAllRead: () => void; + /** Remove a single notification. */ + dismiss: (id: string) => void; + /** Remove all notifications. */ + clearAll: () => void; + /** Load the next page of notifications. */ + loadMore: () => void; + /** Update one or more preference keys. */ + setPreferences: (prefs: Partial) => void; + /** Raw state, exposed for tests/advanced consumers. */ + _state: NotificationState; + _dispatch: React.Dispatch; +} + +const NotificationsContext = createContext(null); + +// ── Toast variant helpers ───────────────────────────────────────────────────── + +const CATEGORY_VARIANT: Record = { + offer: 'default', + repayment: 'default', + alert: 'destructive', + info: 'default', +}; + +// ── Provider ────────────────────────────────────────────────────────────────── + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer( + notificationReducer, + undefined, + () => buildInitialState(loadSavedPreferences()), + ); + + // Subscribe to the Soroban event bus. The hook internally handles + // exponential-backoff reconnection and deduplication. + const { lastEvent } = useEventSubscription(); + + // React to new events from the event bus. + useEffect(() => { + if (!lastEvent) return; + + const notification = mapEventToNotification(lastEvent, state.preferences); + if (!notification) return; + + dispatch({ type: 'ADD', notification }); + + // Fire in-app toast. + toast({ + title: notification.title, + description: notification.body, + variant: CATEGORY_VARIANT[notification.category], + }); + + // Fire OS-level browser notification if permitted and preference is on. + if ( + state.preferences.browserNotifications && + getBrowserNotificationPermission() === 'granted' + ) { + sendBrowserNotification({ + title: notification.title, + body: notification.body, + tag: notification.id, + }); + } + // We intentionally omit `state.preferences` from the dependency array: + // we only want to re-run when lastEvent changes, reading preferences from + // the latest state at that point is safe because the closure captures it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastEvent]); + + // Persist preferences whenever they change. + useEffect(() => { + savePreferences(state.preferences); + }, [state.preferences]); + + const markRead = useCallback((id: string) => dispatch({ type: 'MARK_READ', id }), []); + const markAllRead = useCallback(() => dispatch({ type: 'MARK_ALL_READ' }), []); + const dismiss = useCallback((id: string) => dispatch({ type: 'DISMISS', id }), []); + const clearAll = useCallback(() => dispatch({ type: 'CLEAR_ALL' }), []); + const loadMore = useCallback(() => dispatch({ type: 'LOAD_MORE' }), []); + const setPreferences = useCallback( + (prefs: Partial) => + dispatch({ type: 'SET_PREFERENCES', preferences: prefs }), + [], + ); + + const notificationsByCategory = useCallback( + (category: NotificationCategory) => selectByCategory(state, category), + [state], + ); + + const value = useMemo( + () => ({ + visibleNotifications: selectVisible(state), + notificationsByCategory, + unreadCount: state.unreadCount, + hasMore: state.hasMore, + preferences: state.preferences, + markRead, + markAllRead, + dismiss, + clearAll, + loadMore, + setPreferences, + _state: state, + _dispatch: dispatch, + }), + [ + state, + notificationsByCategory, + markRead, + markAllRead, + dismiss, + clearAll, + loadMore, + setPreferences, + ], + ); + + return ( + + {children} + + ); +} + +// ── Consumer hook ───────────────────────────────────────────────────────────── + +/** + * Access the notification system from any client component. + * Must be used inside `NotificationProvider`. + */ +export function useNotifications(): NotificationsContextValue { + const ctx = useContext(NotificationsContext); + if (!ctx) { + throw new Error('useNotifications must be used inside '); + } + return ctx; +} + +// Re-export PAGE_SIZE so the panel can show "Showing X of Y". +export { PAGE_SIZE }; diff --git a/invofi/apps/frontend/src/lib/notifications/browserNotifications.test.ts b/invofi/apps/frontend/src/lib/notifications/browserNotifications.test.ts new file mode 100644 index 000000000..8c727bc25 --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/browserNotifications.test.ts @@ -0,0 +1,176 @@ +// ── Browser notification API unit tests (issue #255) ───────────────────────── +// @vitest-environment jsdom + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + isBrowserNotificationSupported, + getBrowserNotificationPermission, + requestBrowserNotificationPermission, + sendBrowserNotification, +} from '@/lib/notifications/browserNotifications'; + +// ── Mock helpers ────────────────────────────────────────────────────────────── + +type NotifPermission = 'default' | 'granted' | 'denied'; + +function mockNotificationAPI(permission: NotifPermission = 'default') { + const requestPermission = vi.fn().mockResolvedValue(permission); + const MockNotification = vi.fn() as unknown as { + new (title: string, options?: NotificationOptions): Notification; + permission: NotifPermission; + requestPermission: typeof requestPermission; + }; + (MockNotification as unknown as Record).permission = permission; + (MockNotification as unknown as Record).requestPermission = requestPermission; + + Object.defineProperty(window, 'Notification', { + value: MockNotification, + writable: true, + configurable: true, + }); + + return { MockNotification, requestPermission }; +} + +function removeNotificationAPI() { + try { + Object.defineProperty(window, 'Notification', { + value: undefined, + writable: true, + configurable: true, + }); + } catch { + // Ignore — best effort + } +} + +// ── isBrowserNotificationSupported ─────────────────────────────────────────── + +describe('isBrowserNotificationSupported', () => { + afterEach(() => { + removeNotificationAPI(); + }); + + it('returns true when Notification is in window', () => { + mockNotificationAPI(); + expect(isBrowserNotificationSupported()).toBe(true); + }); + + it('returns false when Notification is absent', () => { + removeNotificationAPI(); + expect(isBrowserNotificationSupported()).toBe(false); + }); +}); + +// ── getBrowserNotificationPermission ───────────────────────────────────────── + +describe('getBrowserNotificationPermission', () => { + afterEach(() => { + removeNotificationAPI(); + }); + + it('returns the current permission when supported', () => { + mockNotificationAPI('granted'); + expect(getBrowserNotificationPermission()).toBe('granted'); + }); + + it('returns "denied" when Notification is unsupported', () => { + removeNotificationAPI(); + expect(getBrowserNotificationPermission()).toBe('denied'); + }); +}); + +// ── requestBrowserNotificationPermission ───────────────────────────────────── + +describe('requestBrowserNotificationPermission', () => { + afterEach(() => { + removeNotificationAPI(); + }); + + it('returns "denied" immediately when API is unsupported', async () => { + removeNotificationAPI(); + const result = await requestBrowserNotificationPermission(); + expect(result).toBe('denied'); + }); + + it('returns "granted" immediately when already granted', async () => { + mockNotificationAPI('granted'); + const result = await requestBrowserNotificationPermission(); + expect(result).toBe('granted'); + }); + + it('returns "denied" immediately when already denied', async () => { + mockNotificationAPI('denied'); + const result = await requestBrowserNotificationPermission(); + expect(result).toBe('denied'); + }); + + it('calls requestPermission and returns its result when default', async () => { + const { requestPermission } = mockNotificationAPI('default'); + requestPermission.mockResolvedValue('granted'); + const result = await requestBrowserNotificationPermission(); + expect(requestPermission).toHaveBeenCalledOnce(); + expect(result).toBe('granted'); + }); + + it('returns "denied" when requestPermission throws', async () => { + const { requestPermission } = mockNotificationAPI('default'); + requestPermission.mockRejectedValue(new Error('blocked')); + const result = await requestBrowserNotificationPermission(); + expect(result).toBe('denied'); + }); +}); + +// ── sendBrowserNotification ─────────────────────────────────────────────────── + +describe('sendBrowserNotification', () => { + beforeEach(() => { + mockNotificationAPI('granted'); + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + configurable: true, + }); + }); + + afterEach(() => { + removeNotificationAPI(); + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + }); + + it('returns false when API is not supported', () => { + removeNotificationAPI(); + expect(sendBrowserNotification({ title: 'T', body: 'B' })).toBe(false); + }); + + it('returns false when permission is not granted', () => { + mockNotificationAPI('default'); + expect(sendBrowserNotification({ title: 'T', body: 'B' })).toBe(false); + }); + + it('returns false when the tab is visible (in-app toast is sufficient)', () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + expect(sendBrowserNotification({ title: 'T', body: 'B' })).toBe(false); + }); + + it('instantiates Notification and returns true when all conditions are met', () => { + const result = sendBrowserNotification({ title: 'Hello', body: 'World', tag: 'tag1' }); + expect(result).toBe(true); + expect(window.Notification as unknown as ReturnType).toHaveBeenCalledWith( + 'Hello', + { body: 'World', tag: 'tag1', icon: '/icon.png' }, + ); + }); + + it('returns false when constructing Notification throws', () => { + (window.Notification as unknown as ReturnType).mockImplementation(() => { + throw new Error('security error'); + }); + expect(sendBrowserNotification({ title: 'T', body: 'B' })).toBe(false); + }); +}); diff --git a/invofi/apps/frontend/src/lib/notifications/browserNotifications.ts b/invofi/apps/frontend/src/lib/notifications/browserNotifications.ts new file mode 100644 index 000000000..cf2a26890 --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/browserNotifications.ts @@ -0,0 +1,91 @@ +// ── Browser Notification API wrapper (issue #255) ──────────────────────────── +// Thin, testable wrapper around window.Notification so components never call +// the native API directly. Fails gracefully when the API is absent or denied. + +/** + * The three possible states of the OS notification permission. + * Matches the native Notification.permission strings. + */ +export type NotificationPermission = 'default' | 'granted' | 'denied'; + +/** + * True when the browser supports the Notification API. + * Always false in SSR (no `window`) or when window.Notification is falsy. + */ +export function isBrowserNotificationSupported(): boolean { + return typeof window !== 'undefined' && typeof window.Notification === 'function'; +} + +/** + * Returns the current permission state, or `'denied'` as a safe default + * when the API is not available. + */ +export function getBrowserNotificationPermission(): NotificationPermission { + if (!isBrowserNotificationSupported()) return 'denied'; + return window.Notification.permission as NotificationPermission; +} + +/** + * Requests browser notification permission from the user. + * + * - Resolves immediately with `'denied'` when unsupported or already denied. + * - Resolves with the granted/denied result after the user responds to the + * browser prompt. + * - Never rejects — callers can treat any result safely. + */ +export async function requestBrowserNotificationPermission(): Promise { + if (!isBrowserNotificationSupported()) return 'denied'; + if (window.Notification.permission === 'granted') return 'granted'; + if (window.Notification.permission === 'denied') return 'denied'; + + + try { + const result = await window.Notification.requestPermission(); + return result as NotificationPermission; + } catch { + // Some browsers (e.g. Firefox in private windows) throw on requestPermission. + return 'denied'; + } +} + +export interface BrowserNotificationOptions { + /** Short headline shown in the OS notification. */ + title: string; + /** Supporting body text. */ + body: string; + /** + * Deduplication tag: the OS replaces an existing notification that has the + * same tag instead of stacking a new one. + */ + tag?: string; +} + +/** + * Fire an OS-level browser notification. + * + * Returns `true` when the notification was sent, `false` when skipped + * (unsupported, denied, or the page is currently focused — in-app toasts + * are sufficient in that case). + */ +export function sendBrowserNotification(opts: BrowserNotificationOptions): boolean { + if (!isBrowserNotificationSupported()) return false; + if (window.Notification.permission !== 'granted') return false; + + // Skip when the user is actively looking at the tab — the in-app toast + // is already visible. + if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + return false; + } + + try { + new window.Notification(opts.title, { + body: opts.body, + tag: opts.tag, + icon: '/icon.png', + }); + return true; + } catch { + // Constructing Notification can throw in some sandboxed environments. + return false; + } +} diff --git a/invofi/apps/frontend/src/lib/notifications/eventMap.test.ts b/invofi/apps/frontend/src/lib/notifications/eventMap.test.ts new file mode 100644 index 000000000..f52ccae4b --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/eventMap.test.ts @@ -0,0 +1,256 @@ +// ── Event map unit tests (issue #255) ───────────────────────────────────────── + +import { describe, it, expect } from 'vitest'; +import { mapEventToNotification, isNotifiableEvent, buildNotificationId } from '@/lib/notifications/eventMap'; +import { DEFAULT_PREFERENCES } from '@/lib/notifications/store'; +import type { ProtocolEvent, OfferCreatedData, InvoiceRepaidData } from '@invofi/sdk'; +import type { NotificationPreferences } from '@/types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +// Use concrete discriminated union members that TypeScript can narrow correctly. + +function makeOffNewEvent(subjectId = 'inv-001', txHash = 'abc123'): ProtocolEvent { + return { + type: 'off_new', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1000, + txHash, + data: { + invoiceId: subjectId, + lender: 'GABC123', + amount: BigInt(1000), + interestRate: 500, + } satisfies OfferCreatedData, + }; +} + +function makeOffAccEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'off_acc', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1001, + txHash: 'hash-acc', + data: { invoiceId: subjectId, lender: 'GABC123', amount: BigInt(1000) }, + }; +} + +function makeOffRejEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'off_rej', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1002, + txHash: 'hash-rej', + data: { invoiceId: subjectId }, + }; +} + +function makeInvOvdEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_ovd', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1003, + txHash: 'hash-ovd', + data: { dueDate: BigInt(1_700_000_000) }, + }; +} + +function makeInvDefEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_def', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1004, + txHash: 'hash-def', + data: { invoiceId: subjectId }, + }; +} + +function makeInvRepEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_rep', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1005, + txHash: 'hash-rep', + data: { + offerId: 'offer-001', + amount: BigInt(1000), + fullyRepaid: true, + } satisfies InvoiceRepaidData, + }; +} + +function makeInvDspEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_dsp', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1006, + txHash: 'hash-dsp', + data: { originator: 'GABC123' }, + }; +} + +function makeInvRslEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_rsl', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1007, + txHash: 'hash-rsl', + data: { newStatus: 'Repaid' }, + }; +} + +function makeInvCxlEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_cxl', + subjectId, + contractId: 'CONTRACT_A', + ledger: 1008, + txHash: 'hash-cxl', + data: { originator: 'GABC123' }, + }; +} + +function makeInvRegEvent(subjectId = 'inv-001'): ProtocolEvent { + return { + type: 'inv_reg', + subjectId, + contractId: 'CONTRACT_A', + ledger: 999, + txHash: 'hash-reg', + data: { originator: 'GABC123', amount: BigInt(1000), dueDate: BigInt(1_700_000_000) }, + }; +} + +const allPrefsOn: NotificationPreferences = { ...DEFAULT_PREFERENCES }; + +const allPrefsOff: NotificationPreferences = { + offer_new: false, + offer_accepted: false, + offer_rejected: false, + invoice_overdue: false, + repayment: false, + dispute: false, + browserNotifications: false, +}; + +// ── mapEventToNotification ──────────────────────────────────────────────────── + +describe('mapEventToNotification', () => { + it('returns null for unmapped event types (inv_reg has no notification)', () => { + const result = mapEventToNotification(makeInvRegEvent(), allPrefsOn); + expect(result).toBeNull(); + }); + + it('returns null when the preference for an event is disabled', () => { + const result = mapEventToNotification(makeOffNewEvent(), allPrefsOff); + expect(result).toBeNull(); + }); + + it('maps off_new to an offer category notification', () => { + const event = makeOffNewEvent('inv-abc', 'tx-xyz'); + const notif = mapEventToNotification(event, allPrefsOn); + expect(notif).not.toBeNull(); + expect(notif!.category).toBe('offer'); + expect(notif!.title).toBe('New offer received'); + expect(notif!.body).toContain('inv-abc'); + expect(notif!.subjectId).toBe('inv-abc'); + expect(notif!.eventType).toBe('off_new'); + expect(notif!.read).toBe(false); + }); + + it('maps off_acc to an offer category notification', () => { + const notif = mapEventToNotification(makeOffAccEvent(), allPrefsOn); + expect(notif!.category).toBe('offer'); + expect(notif!.title).toBe('Offer accepted'); + }); + + it('maps off_rej to an offer category notification', () => { + const notif = mapEventToNotification(makeOffRejEvent(), allPrefsOn); + expect(notif!.category).toBe('offer'); + expect(notif!.title).toBe('Offer rejected'); + }); + + it('maps inv_ovd to an alert category notification', () => { + const notif = mapEventToNotification(makeInvOvdEvent(), allPrefsOn); + expect(notif!.category).toBe('alert'); + expect(notif!.title).toBe('Invoice overdue'); + }); + + it('maps inv_def to an alert category notification', () => { + const notif = mapEventToNotification(makeInvDefEvent(), allPrefsOn); + expect(notif!.category).toBe('alert'); + }); + + it('maps inv_rep to a repayment category notification', () => { + const notif = mapEventToNotification(makeInvRepEvent(), allPrefsOn); + expect(notif!.category).toBe('repayment'); + expect(notif!.title).toBe('Repayment confirmed'); + }); + + it('maps inv_dsp to an alert category notification', () => { + const notif = mapEventToNotification(makeInvDspEvent(), allPrefsOn); + expect(notif!.category).toBe('alert'); + expect(notif!.title).toBe('Dispute raised'); + }); + + it('maps inv_rsl to an info category notification', () => { + const notif = mapEventToNotification(makeInvRslEvent(), allPrefsOn); + expect(notif!.category).toBe('info'); + expect(notif!.title).toBe('Dispute resolved'); + }); + + it('maps inv_cxl to an alert category notification', () => { + const notif = mapEventToNotification(makeInvCxlEvent(), allPrefsOn); + expect(notif!.category).toBe('alert'); + }); + + it('respects individual preference keys', () => { + const prefs: NotificationPreferences = { ...allPrefsOn, repayment: false }; + const notif = mapEventToNotification(makeInvRepEvent(), prefs); + expect(notif).toBeNull(); + }); + + it('includes subjectId in the notification', () => { + const notif = mapEventToNotification(makeOffNewEvent('invoice-xyz'), allPrefsOn); + expect(notif!.subjectId).toBe('invoice-xyz'); + }); +}); + +// ── isNotifiableEvent ───────────────────────────────────────────────────────── + +describe('isNotifiableEvent', () => { + it('returns true for mapped event types', () => { + expect(isNotifiableEvent('off_new')).toBe(true); + expect(isNotifiableEvent('inv_rep')).toBe(true); + expect(isNotifiableEvent('inv_ovd')).toBe(true); + }); + + it('returns false for unmapped event types', () => { + expect(isNotifiableEvent('inv_reg')).toBe(false); + expect(isNotifiableEvent('unknown_event')).toBe(false); + expect(isNotifiableEvent('')).toBe(false); + }); +}); + +// ── buildNotificationId ──────────────────────────────────────────────────────── + +describe('buildNotificationId', () => { + it('produces a stable id from txHash + type + subjectId', () => { + const event = makeOffNewEvent('inv-1', 'hash1'); + expect(buildNotificationId(event)).toBe('hash1:off_new:inv-1'); + }); + + it('produces different ids for different events', () => { + const a = makeOffNewEvent('i1', 'h1'); + const b = makeInvRepEvent('i2'); + expect(buildNotificationId(a)).not.toBe(buildNotificationId(b)); + }); +}); diff --git a/invofi/apps/frontend/src/lib/notifications/eventMap.ts b/invofi/apps/frontend/src/lib/notifications/eventMap.ts new file mode 100644 index 000000000..7f9ea9a78 --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/eventMap.ts @@ -0,0 +1,145 @@ +// ── Event map — ProtocolEvent → AppNotification (issue #255) ───────────────── +// Maps raw Soroban contract events to user-facing AppNotification objects. +// Only events whose subjectId belongs to the current wallet are surfaced. + +import type { ProtocolEvent, ProtocolEventName } from '@invofi/sdk'; +import type { AppNotification, NotificationCategory, NotificationPreferences } from '@/types'; + +// ── Label + category table ──────────────────────────────────────────────────── + +interface EventMeta { + title: string; + body: (subjectId?: string) => string; + category: NotificationCategory; + /** Which preferences key gates this notification. */ + prefKey: keyof Omit; +} + +const EVENT_META: Partial> = { + off_new: { + title: 'New offer received', + body: (id) => `A lender placed an offer on invoice ${id ?? '—'}.`, + category: 'offer', + prefKey: 'offer_new', + }, + off_acc: { + title: 'Offer accepted', + body: (id) => `Your offer on invoice ${id ?? '—'} was accepted.`, + category: 'offer', + prefKey: 'offer_accepted', + }, + off_rej: { + title: 'Offer rejected', + body: (id) => `Your offer on invoice ${id ?? '—'} was rejected.`, + category: 'offer', + prefKey: 'offer_rejected', + }, + off_wdr: { + title: 'Offer withdrawn', + body: (id) => `An offer on invoice ${id ?? '—'} was withdrawn.`, + category: 'offer', + prefKey: 'offer_rejected', + }, + off_def: { + title: 'Offer position defaulted', + body: (id) => `A lender reclaimed their position on invoice ${id ?? '—'}.`, + category: 'alert', + prefKey: 'offer_rejected', + }, + inv_ovd: { + title: 'Invoice overdue', + body: (id) => `Invoice ${id ?? '—'} has been marked overdue.`, + category: 'alert', + prefKey: 'invoice_overdue', + }, + inv_def: { + title: 'Invoice defaulted', + body: (id) => `Invoice ${id ?? '—'} has defaulted.`, + category: 'alert', + prefKey: 'invoice_overdue', + }, + inv_rep: { + title: 'Repayment confirmed', + body: (id) => `Repayment received for invoice ${id ?? '—'}.`, + category: 'repayment', + prefKey: 'repayment', + }, + inv_dsp: { + title: 'Dispute raised', + body: (id) => `A dispute was opened on invoice ${id ?? '—'}.`, + category: 'alert', + prefKey: 'dispute', + }, + inv_rsl: { + title: 'Dispute resolved', + body: (id) => `The dispute on invoice ${id ?? '—'} has been resolved.`, + category: 'info', + prefKey: 'dispute', + }, + inv_sts: { + title: 'Invoice status updated', + body: (id) => `Invoice ${id ?? '—'} status has changed.`, + category: 'info', + prefKey: 'offer_new', + }, + inv_cxl: { + title: 'Invoice cancelled', + body: (id) => `Invoice ${id ?? '—'} has been cancelled.`, + category: 'alert', + prefKey: 'invoice_overdue', + }, +}; + +// ── ID generator ────────────────────────────────────────────────────────────── + +let _seq = 0; +/** Deterministic, stable ID derived from the event so it can be deduplicated. */ +export function buildNotificationId(event: ProtocolEvent): string { + // Use txHash + type + subjectId when available (mimics useEventSubscription dedup key). + if (event.txHash) { + return `${event.txHash}:${event.type}:${event.subjectId ?? ''}`; + } + // Fallback for synthetic/test events. + return `notif-${event.type}-${++_seq}`; +} + +// ── Main mapper ─────────────────────────────────────────────────────────────── + +/** + * Convert a raw ProtocolEvent into an AppNotification. + * + * Returns `null` when: + * - The event type has no registered meta (not user-facing). + * - The user's preferences have disabled this category. + */ +export function mapEventToNotification( + event: ProtocolEvent, + preferences: NotificationPreferences, +): AppNotification | null { + const meta = EVENT_META[event.type as ProtocolEventName]; + if (!meta) return null; + + // Check user preferences (boolean toggle for this event category). + const prefAllowed = preferences[meta.prefKey as keyof NotificationPreferences]; + if (!prefAllowed) return null; + + return { + id: buildNotificationId(event), + title: meta.title, + body: meta.body(event.subjectId ?? undefined), + category: meta.category, + read: false, + createdAt: new Date().toISOString(), + subjectId: event.subjectId ?? undefined, + eventType: event.type, + }; +} + + +/** + * Returns true when an event type has a registered notification mapping. + * Useful for filtering which events should wake the notification provider. + */ +export function isNotifiableEvent(type: string): boolean { + return type in EVENT_META; +} diff --git a/invofi/apps/frontend/src/lib/notifications/store.test.ts b/invofi/apps/frontend/src/lib/notifications/store.test.ts new file mode 100644 index 000000000..c85c2cbfc --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/store.test.ts @@ -0,0 +1,237 @@ +// ── Notification store unit tests (issue #255) ─────────────────────────────── + +import { describe, it, expect } from 'vitest'; +import { + notificationReducer, + buildInitialState, + selectVisible, + selectByCategory, + MAX_NOTIFICATIONS, + PAGE_SIZE, + DEFAULT_PREFERENCES, + type NotificationState, +} from '@/lib/notifications/store'; +import type { AppNotification } from '@/types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeNotification(overrides: Partial = {}): AppNotification { + const id = overrides.id ?? `notif-${Math.random().toString(36).slice(2)}`; + return { + id, + title: 'Test notification', + body: 'Test body', + category: 'info', + read: false, + createdAt: new Date().toISOString(), + eventType: 'inv_sts', + ...overrides, + }; +} + +function initialState(): NotificationState { + return buildInitialState(); +} + +// ── buildInitialState ───────────────────────────────────────────────────────── + +describe('buildInitialState', () => { + it('starts with empty notifications and zero unread count', () => { + const state = buildInitialState(); + expect(state.notifications).toHaveLength(0); + expect(state.unreadCount).toBe(0); + }); + + it('merges saved preferences', () => { + const state = buildInitialState({ offer_new: false, browserNotifications: true }); + expect(state.preferences.offer_new).toBe(false); + expect(state.preferences.browserNotifications).toBe(true); + // Other defaults untouched + expect(state.preferences.repayment).toBe(true); + }); + + it('keeps default preferences when none are saved', () => { + const state = buildInitialState(); + expect(state.preferences).toEqual(DEFAULT_PREFERENCES); + }); +}); + +// ── ADD ─────────────────────────────────────────────────────────────────────── + +describe('ADD action', () => { + it('prepends a notification and increments unreadCount', () => { + const state = notificationReducer(initialState(), { + type: 'ADD', + notification: makeNotification({ id: 'a' }), + }); + expect(state.notifications).toHaveLength(1); + expect(state.notifications[0].id).toBe('a'); + expect(state.unreadCount).toBe(1); + }); + + it('deduplicates notifications with the same id', () => { + let state = initialState(); + const notif = makeNotification({ id: 'dup' }); + state = notificationReducer(state, { type: 'ADD', notification: notif }); + state = notificationReducer(state, { type: 'ADD', notification: notif }); + expect(state.notifications).toHaveLength(1); + expect(state.unreadCount).toBe(1); + }); + + it('prunes oldest notifications when cap is exceeded', () => { + let state = initialState(); + for (let i = 0; i < MAX_NOTIFICATIONS + 5; i++) { + state = notificationReducer(state, { + type: 'ADD', + notification: makeNotification({ id: `n-${i}` }), + }); + } + expect(state.notifications).toHaveLength(MAX_NOTIFICATIONS); + }); + + it('does not count already-read notifications as unread', () => { + const state = notificationReducer(initialState(), { + type: 'ADD', + notification: makeNotification({ read: true }), + }); + expect(state.unreadCount).toBe(0); + }); +}); + +// ── MARK_READ ───────────────────────────────────────────────────────────────── + +describe('MARK_READ action', () => { + it('marks a single notification as read', () => { + let state = notificationReducer(initialState(), { + type: 'ADD', + notification: makeNotification({ id: 'x' }), + }); + state = notificationReducer(state, { type: 'MARK_READ', id: 'x' }); + expect(state.notifications[0].read).toBe(true); + expect(state.unreadCount).toBe(0); + }); + + it('is a no-op for an unknown id', () => { + const base = notificationReducer(initialState(), { + type: 'ADD', + notification: makeNotification({ id: 'y' }), + }); + const after = notificationReducer(base, { type: 'MARK_READ', id: 'z' }); + expect(after.unreadCount).toBe(1); + expect(after.notifications[0].read).toBe(false); + }); +}); + +// ── MARK_ALL_READ ───────────────────────────────────────────────────────────── + +describe('MARK_ALL_READ action', () => { + it('marks all notifications as read and zeroes unreadCount', () => { + let state = initialState(); + state = notificationReducer(state, { type: 'ADD', notification: makeNotification({ id: '1' }) }); + state = notificationReducer(state, { type: 'ADD', notification: makeNotification({ id: '2' }) }); + state = notificationReducer(state, { type: 'MARK_ALL_READ' }); + expect(state.unreadCount).toBe(0); + expect(state.notifications.every((n) => n.read)).toBe(true); + }); +}); + +// ── DISMISS ─────────────────────────────────────────────────────────────────── + +describe('DISMISS action', () => { + it('removes a notification by id', () => { + let state = notificationReducer(initialState(), { + type: 'ADD', + notification: makeNotification({ id: 'del' }), + }); + state = notificationReducer(state, { type: 'DISMISS', id: 'del' }); + expect(state.notifications).toHaveLength(0); + expect(state.unreadCount).toBe(0); + }); +}); + +// ── CLEAR_ALL ───────────────────────────────────────────────────────────────── + +describe('CLEAR_ALL action', () => { + it('empties the notification list and resets pagination', () => { + let state = initialState(); + state = notificationReducer(state, { type: 'ADD', notification: makeNotification() }); + state = notificationReducer(state, { type: 'CLEAR_ALL' }); + expect(state.notifications).toHaveLength(0); + expect(state.unreadCount).toBe(0); + expect(state.page).toBe(1); + expect(state.hasMore).toBe(false); + }); +}); + +// ── LOAD_MORE ───────────────────────────────────────────────────────────────── + +describe('LOAD_MORE action', () => { + it('increments the page', () => { + const state = notificationReducer(initialState(), { type: 'LOAD_MORE' }); + expect(state.page).toBe(2); + }); +}); + +// ── SET_PREFERENCES ─────────────────────────────────────────────────────────── + +describe('SET_PREFERENCES action', () => { + it('partially updates preferences', () => { + let state = initialState(); + state = notificationReducer(state, { + type: 'SET_PREFERENCES', + preferences: { offer_new: false, browserNotifications: true }, + }); + expect(state.preferences.offer_new).toBe(false); + expect(state.preferences.browserNotifications).toBe(true); + expect(state.preferences.repayment).toBe(true); // unchanged + }); + + it('full update replaces all listed keys', () => { + const newPrefs = { + offer_new: false, + offer_accepted: false, + offer_rejected: false, + invoice_overdue: false, + repayment: false, + dispute: false, + browserNotifications: true, + }; + const state = notificationReducer( + initialState(), + { type: 'SET_PREFERENCES', preferences: newPrefs }, + ); + expect(state.preferences).toEqual(newPrefs); + }); +}); + +// ── Selectors ───────────────────────────────────────────────────────────────── + +describe('selectVisible', () => { + it('returns at most PAGE_SIZE notifications on page 1', () => { + let state = initialState(); + for (let i = 0; i < PAGE_SIZE + 5; i++) { + state = notificationReducer(state, { + type: 'ADD', + notification: makeNotification({ id: `v-${i}` }), + }); + } + expect(selectVisible(state)).toHaveLength(PAGE_SIZE); + }); +}); + +describe('selectByCategory', () => { + it('filters notifications by category', () => { + let state = initialState(); + state = notificationReducer(state, { + type: 'ADD', + notification: makeNotification({ id: 'offer-1', category: 'offer' }), + }); + state = notificationReducer(state, { + type: 'ADD', + notification: makeNotification({ id: 'alert-1', category: 'alert' }), + }); + expect(selectByCategory(state, 'offer')).toHaveLength(1); + expect(selectByCategory(state, 'alert')).toHaveLength(1); + expect(selectByCategory(state, 'repayment')).toHaveLength(0); + }); +}); diff --git a/invofi/apps/frontend/src/lib/notifications/store.ts b/invofi/apps/frontend/src/lib/notifications/store.ts new file mode 100644 index 000000000..6141acfa8 --- /dev/null +++ b/invofi/apps/frontend/src/lib/notifications/store.ts @@ -0,0 +1,153 @@ +// ── Notification store — pure reducer (issue #255) ─────────────────────────── +// State is held in NotificationProvider via useReducer; this file owns the +// reducer, action types, default state, and the selector helpers that +// components consume via useNotifications(). + +import type { AppNotification, NotificationCategory, NotificationPreferences } from '@/types'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Maximum notifications kept in memory before the oldest are pruned. */ +export const MAX_NOTIFICATIONS = 200; + +/** Number of notifications rendered per "page" in the panel. */ +export const PAGE_SIZE = 20; + +export const DEFAULT_PREFERENCES: NotificationPreferences = { + offer_new: true, + offer_accepted: true, + offer_rejected: true, + invoice_overdue: true, + repayment: true, + dispute: true, + browserNotifications: false, +}; + +// ── State ───────────────────────────────────────────────────────────────────── + +export interface NotificationState { + /** All notifications, newest-first. */ + notifications: AppNotification[]; + /** How many are unread. */ + unreadCount: number; + /** Which page the panel is showing (1-based). */ + page: number; + /** True when there are more notifications beyond the current page. */ + hasMore: boolean; + /** User preferences (persisted separately to localStorage). */ + preferences: NotificationPreferences; +} + +export function buildInitialState( + savedPreferences?: Partial, +): NotificationState { + return { + notifications: [], + unreadCount: 0, + page: 1, + hasMore: false, + preferences: { ...DEFAULT_PREFERENCES, ...savedPreferences }, + }; +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + +export type NotificationAction = + | { type: 'ADD'; notification: AppNotification } + | { type: 'MARK_READ'; id: string } + | { type: 'MARK_ALL_READ' } + | { type: 'DISMISS'; id: string } + | { type: 'CLEAR_ALL' } + | { type: 'LOAD_MORE' } + | { type: 'SET_PREFERENCES'; preferences: Partial }; + +// ── Reducer ─────────────────────────────────────────────────────────────────── + +export function notificationReducer( + state: NotificationState, + action: NotificationAction, +): NotificationState { + switch (action.type) { + case 'ADD': { + // Deduplicate by id (listenToEvents can fire the same event twice). + if (state.notifications.some((n) => n.id === action.notification.id)) { + return state; + } + const updated = [action.notification, ...state.notifications].slice(0, MAX_NOTIFICATIONS); + const unread = updated.filter((n) => !n.read).length; + return { + ...state, + notifications: updated, + unreadCount: unread, + hasMore: updated.length > state.page * PAGE_SIZE, + }; + } + + case 'MARK_READ': { + const updated = state.notifications.map((n) => + n.id === action.id ? { ...n, read: true } : n, + ); + return { + ...state, + notifications: updated, + unreadCount: updated.filter((n) => !n.read).length, + }; + } + + case 'MARK_ALL_READ': { + const updated = state.notifications.map((n) => ({ ...n, read: true })); + return { ...state, notifications: updated, unreadCount: 0 }; + } + + case 'DISMISS': { + const updated = state.notifications.filter((n) => n.id !== action.id); + return { + ...state, + notifications: updated, + unreadCount: updated.filter((n) => !n.read).length, + hasMore: updated.length > state.page * PAGE_SIZE, + }; + } + + case 'CLEAR_ALL': + return { ...state, notifications: [], unreadCount: 0, page: 1, hasMore: false }; + + case 'LOAD_MORE': { + const nextPage = state.page + 1; + return { + ...state, + page: nextPage, + hasMore: state.notifications.length > nextPage * PAGE_SIZE, + }; + } + + case 'SET_PREFERENCES': + return { + ...state, + preferences: { ...state.preferences, ...action.preferences }, + }; + + default: + return state; + } +} + +// ── Selectors ───────────────────────────────────────────────────────────────── + +/** + * Returns the slice of notifications currently visible in the panel + * (respects pagination). + */ +export function selectVisible(state: NotificationState): AppNotification[] { + return state.notifications.slice(0, state.page * PAGE_SIZE); +} + +/** + * Filters visible notifications by category for tab rendering. + */ +export function selectByCategory( + state: NotificationState, + category: NotificationCategory, +): AppNotification[] { + return selectVisible(state).filter((n) => n.category === category); +} diff --git a/invofi/apps/frontend/src/types/index.ts b/invofi/apps/frontend/src/types/index.ts index fb162869c..cd80c6cc9 100644 --- a/invofi/apps/frontend/src/types/index.ts +++ b/invofi/apps/frontend/src/types/index.ts @@ -171,3 +171,46 @@ export type { ScoreBreakdown, } from './matching'; export { DEFAULT_PREFERENCES, serializePreferences, deserializePreferences } from './matching'; + +// ── Real-time notification system (issue #255) ──────────────────────────────── + +/** Broad category used for panel filtering tabs. */ +export type NotificationCategory = 'offer' | 'repayment' | 'alert' | 'info'; + +/** A single user-facing notification derived from a Soroban contract event. */ +export interface AppNotification { + /** Unique identifier (generated client-side). */ + id: string; + /** Human-readable title, e.g. "New offer received". */ + title: string; + /** Supporting detail text. */ + body: string; + /** Category determines the panel tab and the icon. */ + category: NotificationCategory; + /** Whether the user has seen / clicked this notification. */ + read: boolean; + /** ISO timestamp when the notification was created. */ + createdAt: string; + /** Invoice or offer id for deep-link navigation (optional). */ + subjectId?: string; + /** Raw contract event type that triggered this notification. */ + eventType: string; +} + +/** Per-event-type opt-in configuration persisted to localStorage. */ +export interface NotificationPreferences { + /** Show notification when a new offer arrives on one of the user's invoices. */ + offer_new: boolean; + /** Show notification when an offer is accepted. */ + offer_accepted: boolean; + /** Show notification when an offer is rejected or withdrawn. */ + offer_rejected: boolean; + /** Show notification when an invoice is marked overdue. */ + invoice_overdue: boolean; + /** Show notification when a repayment is made. */ + repayment: boolean; + /** Show notification when a dispute is raised or resolved. */ + dispute: boolean; + /** Also send OS-level browser notifications (requires permission grant). */ + browserNotifications: boolean; +} diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index 723e86dd1..deb7d5887 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -38,6 +38,10 @@ export default defineConfig({ 'src/hooks/useDebounce.ts', 'src/hooks/useLocalStorage.ts', 'src/hooks/useMediaQuery.ts', + // Notification system (issue #255) + 'src/lib/notifications/store.ts', + 'src/lib/notifications/eventMap.ts', + 'src/lib/notifications/browserNotifications.ts', ], }, },