mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 23:11:13 -05:00
feat: Implement Novu Inbox component
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
131
src/hooks/useNovuTheme.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user