feat: Implement Novu Inbox component

This commit is contained in:
gpt-engineer-app[bot]
2025-10-01 12:34:02 +00:00
parent 27b0a3ffff
commit 3436e317b5
5 changed files with 588 additions and 127 deletions

View File

@@ -1,94 +1,30 @@
import { useState } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Inbox } from '@novu/react';
import { useNovuNotifications } from '@/hooks/useNovuNotifications';
import { Separator } from '@/components/ui/separator';
import { useNovuTheme } from '@/hooks/useNovuTheme';
import { useNavigate } from 'react-router-dom';
export function NotificationCenter() {
const { notifications, unreadCount, markAsRead, markAllAsRead, isEnabled } = useNovuNotifications();
const [open, setOpen] = useState(false);
const { applicationIdentifier, subscriberId, isEnabled } = useNovuNotifications();
const appearance = useNovuTheme();
const navigate = useNavigate();
if (!isEnabled) {
return null;
}
const handleNotificationClick = (notification: any) => {
// Handle navigation based on notification payload
if (notification.data?.url) {
navigate(notification.data.url);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
>
{unreadCount > 9 ? '9+' : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">Notifications</h3>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={markAllAsRead}
className="text-xs"
>
Mark all as read
</Button>
)}
</div>
<ScrollArea className="h-[400px]">
{notifications.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No notifications yet</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 cursor-pointer hover:bg-muted/50 transition-colors ${
!notification.read ? 'bg-muted/30' : ''
}`}
onClick={() => {
if (!notification.read) {
markAsRead(notification.id);
}
// Handle CTA action if exists
if (notification.cta) {
// Navigate or perform action based on notification.cta
}
}}
>
<div className="flex items-start gap-3">
{!notification.read && (
<div className="w-2 h-2 rounded-full bg-primary mt-1.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm break-words">{notification.content}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(notification.createdAt).toLocaleString()}
</p>
</div>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
<Inbox
applicationIdentifier={applicationIdentifier}
subscriberId={subscriberId}
appearance={appearance}
onNotificationClick={handleNotificationClick}
/>
);
}

View File

@@ -1,55 +1,15 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
export interface NotificationItem {
id: string;
content: string;
read: boolean;
createdAt: string;
cta?: {
type: string;
data: any;
};
}
export function useNovuNotifications() {
const { user } = useAuth();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const applicationIdentifier = import.meta.env.VITE_NOVU_APPLICATION_IDENTIFIER;
const isEnabled = !!applicationIdentifier && !!user;
useEffect(() => {
if (!isEnabled) {
setIsLoading(false);
return;
}
// TODO: Initialize Novu Headless SDK when configuration is complete
// This will require the @novu/headless package to be properly configured
setIsLoading(false);
}, [isEnabled, user]);
const markAsRead = async (notificationId: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
const markAllAsRead = async () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
};
const subscriberId = user?.id;
const isEnabled = !!applicationIdentifier && !!subscriberId;
return {
notifications,
unreadCount,
isLoading,
markAsRead,
markAllAsRead,
applicationIdentifier,
subscriberId,
isEnabled,
};
}

131
src/hooks/useNovuTheme.ts Normal file
View File

@@ -0,0 +1,131 @@
import { useTheme } from '@/components/theme/ThemeProvider';
import { useMemo } from 'react';
export function useNovuTheme() {
const { theme } = useTheme();
const appearance = useMemo(() => {
// Get computed styles to access CSS variables
const root = document.documentElement;
const style = getComputedStyle(root);
return {
variables: {
// Colors
colorBackground: `hsl(var(--background))`,
colorForeground: `hsl(var(--foreground))`,
colorPrimary: `hsl(var(--primary))`,
colorPrimaryForeground: `hsl(var(--primary-foreground))`,
colorSecondary: `hsl(var(--secondary))`,
colorSecondaryForeground: `hsl(var(--secondary-foreground))`,
colorCounter: `hsl(var(--primary))`,
colorCounterForeground: `hsl(var(--primary-foreground))`,
// Notification item colors
colorNeutral: `hsl(var(--muted))`,
colorNeutralForeground: `hsl(var(--muted-foreground))`,
// Border and divider
colorBorder: `hsl(var(--border))`,
// Border radius
borderRadius: `var(--radius)`,
// Font
fontFamily: style.getPropertyValue('font-family') || 'inherit',
fontSize: '14px',
},
elements: {
bellContainer: {
width: '40px',
height: '40px',
},
bell: {
width: '20px',
height: '20px',
color: `hsl(var(--foreground))`,
},
bellDot: {
backgroundColor: `hsl(var(--primary))`,
},
popover: {
boxShadow: `var(--shadow-card)`,
border: `1px solid hsl(var(--border))`,
borderRadius: `calc(var(--radius) + 4px)`,
},
notificationItem: {
transition: 'var(--transition-smooth)',
'&:hover': {
backgroundColor: `hsl(var(--muted) / 0.5)`,
},
},
notificationItemRead: {
opacity: '0.7',
},
notificationItemUnread: {
backgroundColor: `hsl(var(--muted) / 0.3)`,
borderLeft: `3px solid hsl(var(--primary))`,
},
notificationDot: {
backgroundColor: `hsl(var(--primary))`,
width: '8px',
height: '8px',
},
notificationTitle: {
fontWeight: '500',
color: `hsl(var(--foreground))`,
},
notificationDescription: {
color: `hsl(var(--muted-foreground))`,
},
notificationTimestamp: {
fontSize: '12px',
color: `hsl(var(--muted-foreground))`,
},
notificationPrimaryAction: {
backgroundColor: `hsl(var(--primary))`,
color: `hsl(var(--primary-foreground))`,
borderRadius: `var(--radius)`,
padding: '8px 16px',
transition: 'var(--transition-smooth)',
'&:hover': {
opacity: '0.9',
},
},
notificationSecondaryAction: {
backgroundColor: `hsl(var(--secondary))`,
color: `hsl(var(--secondary-foreground))`,
borderRadius: `var(--radius)`,
padding: '8px 16px',
transition: 'var(--transition-smooth)',
'&:hover': {
backgroundColor: `hsl(var(--secondary) / 0.8)`,
},
},
loader: {
color: `hsl(var(--primary))`,
},
emptyNotifications: {
color: `hsl(var(--muted-foreground))`,
textAlign: 'center',
padding: '32px 16px',
},
header: {
borderBottom: `1px solid hsl(var(--border))`,
padding: '16px',
},
headerTitle: {
fontSize: '16px',
fontWeight: '600',
color: `hsl(var(--foreground))`,
},
footer: {
borderTop: `1px solid hsl(var(--border))`,
padding: '12px 16px',
},
},
};
}, [theme]);
return appearance;
}