Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions invofi/apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,17 @@
"imBusinessBtn": "I'm a Business",
"imLenderBtn": "I'm a Lender"
}
},
"Notifications": {
"title": "Notifications",
"markAllRead": "Mark all read",
"emptyTitle": "No notifications",
"emptyDesc": "Events from your invoices and offers will appear here.",
"aria": "Notifications",
"ariaUnread": "Notifications ({count} unread)",
"justNow": "just now",
"minutesAgo": "{m}m ago",
"hoursAgo": "{h}h ago",
"daysAgo": "{d}d ago"
}
}
199 changes: 199 additions & 0 deletions invofi/apps/frontend/src/components/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use client';

/**
* NotificationBell
*
* A bell icon for the Navbar with an unread badge and a popover panel that
* lists the current user's notifications (issue #179). Uses the same
* "click-outside-to-close" popover pattern as the keyboard-shortcuts help
* in Navbar.tsx.
*
* Behaviour:
* - Unread count badge (red dot) on the bell icon.
* - Click opens a dropdown listing notifications (newest first).
* - Unread items have a blue left indicator; clicking them marks them read.
* - "Mark all read" button at the top of the list.
* - Empty state when there are no notifications at all.
* - Dropdown closes on outside click, Escape key, or Bell re-click.
*/

import { useCallback, useEffect, useRef, useState } from 'react';
import { Bell, BellDot, CheckCheck, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useNotifications, useUnreadCount } from '@/hooks/useNotifications';
import type { AppNotification } from '@/types';

/** Format a relative time string for notification timestamps. */
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

export function NotificationBell() {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const toggleRef = useRef<HTMLButtonElement | null>(null);

const { notifications, loading, markAsRead, markAllRead } = useNotifications();
const { count: unreadCount } = useUnreadCount();

const hasUnread = unreadCount > 0;

// Close on outside click.
useEffect(() => {
if (!open) return;
const handleOutsideClick = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
toggleRef.current &&
!toggleRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
// Delay adding the listener to avoid the same click that opened the panel
// from immediately closing it.
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleOutsideClick);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [open]);

// Close on Escape.
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [open]);

// Focus management: move focus into the panel on open.
useEffect(() => {
if (!open) return;
const firstFocusable = panelRef.current?.querySelector<HTMLElement>(
'button, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}, [open]);

const handleItemClick = useCallback(
async (n: AppNotification) => {
if (!n.read_at) {
await markAsRead(n.id);
}
},
[markAsRead],
);

return (
<div className="relative">
<button
ref={toggleRef}
onClick={() => setOpen((v) => !v)}
className="relative p-2 rounded-md text-muted-foreground hover:bg-accent transition-colors"
aria-label={hasUnread ? `Notifications (${unreadCount} unread)` : 'Notifications'}
aria-expanded={open}
>
{hasUnread ? (
<>
<BellDot className="h-5 w-5" />
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</>
) : (
<Bell className="h-5 w-5" />
)}
</button>

{open && (
<>
{/* Overlay for click-outside on mobile (also catches clicks on the bell itself) */}
<div
className="fixed inset-0 z-40"
aria-hidden
onClick={() => setOpen(false)}
/>

<div
ref={panelRef}
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full mt-2 z-50 w-80 rounded-lg border border-border bg-background shadow-lg overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
{hasUnread && (
<button
onClick={() => markAllRead()}
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700 transition-colors"
aria-label="Mark all as read"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)}
</div>

{/* List */}
<div className="max-h-80 overflow-y-auto" role="list" aria-label="Notification list">
{!notifications || notifications.length === 0 ? (
<div className="flex flex-col items-center py-10 px-4 text-center">
<Inbox className="h-8 w-8 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium text-muted-foreground">No notifications</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Events from your invoices and offers will appear here.
</p>
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => handleItemClick(n)}
className={cn(
'w-full text-left px-4 py-3 border-b border-border last:border-0 hover:bg-accent/50 transition-colors',
!n.read_at && 'bg-blue-50/50 dark:bg-blue-950/20',
)}
aria-label={`${n.title}${n.read_at ? '' : ' (unread)'}`}
>
<div className="flex items-start gap-3">
{/* Unread indicator */}
{!n.read_at && (
<span className="mt-1.5 flex h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm truncate', !n.read_at ? 'font-semibold text-foreground' : 'text-foreground')}>
{n.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{n.body}
</p>
<p className="text-[10px] text-muted-foreground/60 mt-1">
{timeAgo(n.created_at)}
</p>
</div>
</div>
</button>
))
)}
</div>
</div>
</>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Component smoke tests for NotificationBell (issue #179).
*
* Uses vi.mock to isolate the component from react-query/event-subscription
* plumbing: the two hooks it consumes (useNotifications, useUnreadCount) are
* replaced with controllable stubs. Run with `NODE_ENV=test npx vitest run`
* from apps/frontend.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { NotificationBell } from '@/components/NotificationBell';
import type { AppNotification } from '@/types';

// ── Hook stubs ────────────────────────────────────────────────────────────

const notificationsState = {
notifications: null as AppNotification[] | null,
loading: false,
error: null as string | null,
markAsRead: vi.fn().mockResolvedValue(undefined),
markAllRead: vi.fn().mockResolvedValue(undefined),
};

const unreadState = {
count: 0,
loading: false,
};

vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => notificationsState,
useUnreadCount: () => unreadState,
}));

// ── Fixtures ───────────────────────────────────────────────────────────────

function makeNotification(overrides: Partial<AppNotification> = {}): AppNotification {
return {
id: 'n1',
user_id: 'user-1',
type: 'offer_accepted',
title: 'Offer accepted',
body: 'Your offer on invoice inv_001 was accepted (1000 units).',
payload: { invoiceId: 'inv_001' },
read_at: null,
created_at: new Date().toISOString(),
...overrides,
};
}

beforeEach(() => {
notificationsState.notifications = null;
notificationsState.loading = false;
notificationsState.error = null;
notificationsState.markAsRead = vi.fn().mockResolvedValue(undefined);
notificationsState.markAllRead = vi.fn().mockResolvedValue(undefined);
unreadState.count = 0;
unreadState.loading = false;
});

// ── Tests ─────────────────────────────────────────────────────────────────

describe('NotificationBell', () => {
it('renders a bell button with no unread badge by default', () => {
render(<NotificationBell />);
const button = screen.getByRole('button', { name: 'Notifications' });
expect(button).toBeTruthy();
});

it('shows unread count badge when there are unread notifications', () => {
unreadState.count = 3;
render(<NotificationBell />);
const button = screen.getByRole('button', { name: 'Notifications (3 unread)' });
expect(button).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
});

it('caps the badge count at 99+', () => {
unreadState.count = 150;
render(<NotificationBell />);
expect(screen.getByText('99+')).toBeTruthy();
});

it('renders an empty state when there are no notifications', () => {
notificationsState.notifications = [];
render(<NotificationBell />);
fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('No notifications')).toBeTruthy();
});

it('opens the panel and lists notifications on click', () => {
unreadState.count = 2;
notificationsState.notifications = [
makeNotification({ id: 'n1', title: 'Offer accepted', body: 'Your offer on invoice inv_001 was accepted (1000 units).' }),
makeNotification({ id: 'n2', type: 'invoice_repaid', title: 'Invoice fully repaid', body: 'Invoice inv_002 received a repayment.', read_at: new Date().toISOString() }),
];
render(<NotificationBell />);
fireEvent.click(screen.getByRole('button', { name: 'Notifications (2 unread)' }));
expect(screen.getByText('Offer accepted')).toBeTruthy();
expect(screen.getByText('Invoice fully repaid')).toBeTruthy();
});

it('calls markAllRead from the panel header', () => {
unreadState.count = 1;
notificationsState.notifications = [makeNotification()];
render(<NotificationBell />);
fireEvent.click(screen.getByRole('button', { name: 'Notifications (1 unread)' }));
fireEvent.click(screen.getByRole('button', { name: 'Mark all as read' }));
expect(notificationsState.markAllRead).toHaveBeenCalledTimes(1);
});

it('marks a single notification read when an unread item is clicked', () => {
unreadState.count = 1;
notificationsState.notifications = [makeNotification({ id: 'n1' })];
render(<NotificationBell />);
fireEvent.click(screen.getByRole('button', { name: 'Notifications (1 unread)' }));
fireEvent.click(screen.getByRole('button', { name: 'Offer accepted (unread)' }));
expect(notificationsState.markAsRead).toHaveBeenCalledWith('n1');
});
});
2 changes: 2 additions & 0 deletions invofi/apps/frontend/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useWallet } from "@/components/auth/WalletProvider";
import { supabase } from "@/lib/supabase";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { NavbarEventIndicator } from "@/components/NavbarEventIndicator";
import { NotificationBell } from "@/components/NotificationBell";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";

import { useTranslations } from 'next-intl';
Expand Down Expand Up @@ -191,6 +192,7 @@ export function Navbar() {
{/* Right side */}
<div className="flex items-center gap-3">
<NavbarEventIndicator />
<NotificationBell />

{/* Keyboard shortcuts help */}
<div className="relative">
Expand Down
17 changes: 16 additions & 1 deletion invofi/apps/frontend/src/components/layout/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { WalletProvider } from '@/components/auth/WalletProvider';
import { useNotificationSeeder } from '@/hooks/useNotifications';

/**
* Mounts the notification seeder once at the app root. It subscribes to the
* global protocol event stream and persists user-facing notifications
* (issue #179). Rendered inside QueryClientProvider so react-query hooks
* have a client available.
*/
function NotificationSeeder() {
useNotificationSeeder();
return null;
}

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
Expand All @@ -14,7 +26,10 @@ export function Providers({ children }: { children: React.ReactNode }) {

return (
<QueryClientProvider client={queryClient}>
<WalletProvider>{children}</WalletProvider>
<WalletProvider>
{children}
<NotificationSeeder />
</WalletProvider>
</QueryClientProvider>
);
}
Loading
Loading