diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..310da59 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,141 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { notificationsApi } from '@/services/api'; + +// Query keys +const notificationKeys = { + all: ['notifications'] as const, + list: (params?: { page?: number; limit?: number; unreadOnly?: boolean }) => + [...notificationKeys.all, 'list', params] as const, + unreadCount: () => [...notificationKeys.all, 'unread-count'] as const, + preferences: () => [...notificationKeys.all, 'preferences'] as const, +}; + +// Types +export interface Notification { + id: string; + user_id: string; + tenant_id: string; + type: 'info' | 'success' | 'warning' | 'error'; + channel: 'in_app' | 'email' | 'push' | 'sms'; + title: string; + body: string; + action_url?: string; + action_label?: string; + read_at?: string; + sent_at: string; + created_at: string; +} + +export interface NotificationPreferences { + email_enabled: boolean; + push_enabled: boolean; + in_app_enabled: boolean; + sms_enabled: boolean; + marketing_enabled: boolean; + digest_frequency: 'realtime' | 'daily' | 'weekly' | 'never'; +} + +export interface PaginatedNotifications { + items: Notification[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// ==================== Queries ==================== + +export function useNotifications(params?: { page?: number; limit?: number; unreadOnly?: boolean }) { + return useQuery({ + queryKey: notificationKeys.list(params), + queryFn: () => notificationsApi.list(params) as Promise, + }); +} + +export function useUnreadCount() { + return useQuery({ + queryKey: notificationKeys.unreadCount(), + queryFn: () => notificationsApi.getUnreadCount() as Promise<{ count: number }>, + refetchInterval: 30000, // Refetch every 30 seconds + }); +} + +export function useNotificationPreferences() { + return useQuery({ + queryKey: notificationKeys.preferences(), + queryFn: () => notificationsApi.getPreferences() as Promise, + }); +} + +// ==================== Mutations ==================== + +export function useMarkAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => notificationsApi.markAsRead(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} + +export function useMarkAllAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => notificationsApi.markAllAsRead(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} + +export function useUpdatePreferences() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (preferences: Partial) => + notificationsApi.updatePreferences(preferences as Record), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() }); + }, + }); +} + +// ==================== Helper functions ==================== + +export function getNotificationTypeColor(type: Notification['type']): string { + const colors: Record = { + info: 'blue', + success: 'green', + warning: 'yellow', + error: 'red', + }; + return colors[type]; +} + +export function getNotificationChannelLabel(channel: Notification['channel']): string { + const labels: Record = { + in_app: 'In-App', + email: 'Email', + push: 'Push', + sms: 'SMS', + }; + return labels[channel]; +} + +export function formatNotificationTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.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 date.toLocaleDateString(); +} diff --git a/src/pages/dashboard/notifications/NotificationsPage.tsx b/src/pages/dashboard/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..7ce9e4b --- /dev/null +++ b/src/pages/dashboard/notifications/NotificationsPage.tsx @@ -0,0 +1,275 @@ +import { useState } from 'react'; +import { Bell, CheckCheck, ExternalLink, Settings, Mail, Smartphone, MessageSquare } from 'lucide-react'; +import { + useNotifications, + useUnreadCount, + useMarkAsRead, + useMarkAllAsRead, + useNotificationPreferences, + useUpdatePreferences, + getNotificationTypeColor, + formatNotificationTime, + type Notification, +} from '@/hooks/useNotifications'; + +function NotificationItem({ + notification, + onMarkAsRead, +}: { + notification: Notification; + onMarkAsRead: (id: string) => void; +}) { + const isUnread = !notification.read_at; + const typeColor = getNotificationTypeColor(notification.type); + + const colorClasses: Record = { + blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400', + green: 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400', + yellow: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/20 dark:text-yellow-400', + red: 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400', + }; + + return ( +
+
+ +
+
+
+
+

{notification.title}

+

{notification.body}

+
+ + {formatNotificationTime(notification.sent_at)} + +
+
+ {notification.action_url && ( + + {notification.action_label || 'View'} + + + )} + {isUnread && ( + + )} +
+
+
+ ); +} + +function PreferencesSection() { + const { data: preferences, isLoading } = useNotificationPreferences(); + const updateMutation = useUpdatePreferences(); + + const handleToggle = (key: string) => { + if (!preferences) return; + updateMutation.mutate({ + [key]: !(preferences as any)[key], + }); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + const channels = [ + { key: 'email_enabled', label: 'Email', icon: Mail, description: 'Receive notifications via email' }, + { key: 'push_enabled', label: 'Push', icon: Smartphone, description: 'Receive browser push notifications' }, + { key: 'in_app_enabled', label: 'In-App', icon: Bell, description: 'Show notifications in the app' }, + { key: 'sms_enabled', label: 'SMS', icon: MessageSquare, description: 'Receive SMS notifications' }, + ]; + + return ( +
+ {channels.map(({ key, label, icon: Icon, description }) => ( +
+
+
+ +
+
+

{label}

+

{description}

+
+
+ +
+ ))} +
+ ); +} + +export default function NotificationsPage() { + const [activeTab, setActiveTab] = useState<'all' | 'unread' | 'settings'>('all'); + const [page, setPage] = useState(1); + + const { data: notifications, isLoading } = useNotifications({ + page, + limit: 20, + unreadOnly: activeTab === 'unread', + }); + const { data: unreadData } = useUnreadCount(); + const markAsReadMutation = useMarkAsRead(); + const markAllAsReadMutation = useMarkAllAsRead(); + + const handleMarkAsRead = (id: string) => { + markAsReadMutation.mutate(id); + }; + + const handleMarkAllAsRead = () => { + if (window.confirm('Mark all notifications as read?')) { + markAllAsReadMutation.mutate(); + } + }; + + const tabs = [ + { id: 'all', label: 'All', count: notifications?.total }, + { id: 'unread', label: 'Unread', count: unreadData?.count }, + { id: 'settings', label: 'Settings', icon: Settings }, + ]; + + return ( +
+ {/* Header */} +
+
+

Notifications

+

+ Stay updated with your latest notifications +

+
+ {activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && ( + + )} +
+ + {/* Tabs */} +
+ +
+ + {/* Content */} + {activeTab === 'settings' ? ( +
+

+ Notification Preferences +

+ +
+ ) : ( +
+ {isLoading ? ( +
+
+
+ ) : notifications?.items.length ? ( + <> + {notifications.items.map((notification: Notification) => ( + + ))} + + {/* Pagination */} + {notifications.totalPages > 1 && ( +
+ + + Page {page} of {notifications.totalPages} + + +
+ )} + + ) : ( +
+ +

+ {activeTab === 'unread' ? 'No unread notifications' : 'No notifications yet'} +

+
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/dashboard/notifications/index.ts b/src/pages/dashboard/notifications/index.ts new file mode 100644 index 0000000..503247d --- /dev/null +++ b/src/pages/dashboard/notifications/index.ts @@ -0,0 +1 @@ +export { default as NotificationsPage } from './NotificationsPage'; diff --git a/src/pages/dashboard/rbac/index.ts b/src/pages/dashboard/rbac/index.ts new file mode 100644 index 0000000..0633c4b --- /dev/null +++ b/src/pages/dashboard/rbac/index.ts @@ -0,0 +1 @@ +export { default as RolesPage } from './RolesPage'; diff --git a/src/router/index.tsx b/src/router/index.tsx index d368834..356d6bd 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -63,6 +63,12 @@ const MyNetworkPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ de const NodeDetailPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.NodeDetailPage }))); const MLMMyEarningsPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.MyEarningsPage }))); +// Lazy loaded pages - RBAC +const RolesPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RolesPage }))); + +// Lazy loaded pages - Notifications +const NotificationsPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.NotificationsPage }))); + // Lazy loaded pages - Admin const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings }))); const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage }))); @@ -197,6 +203,12 @@ export function AppRouter() { } /> } /> } /> + + {/* RBAC routes */} + } /> + + {/* Notifications routes */} + } /> {/* Superadmin routes */}