Implement comprehensive notification system for vouches and praise

- Add NotificationDropdown component with filtering (all/pending/unread)
- Create NotificationItem component with accept/reject/assign actions
- Implement notification service with mock data for vouches and praise
- Add data types for Notification, Vouch, Praise, and RCard categories
- Include default rCard categories (Business, Colleague, Family, Friend, etc.)
- Add rCard assignment dialog for organizing accepted vouches/praise
- Replace static notification badge with dynamic notification system
- Support real-time notification management with state updates

Features:
- Accept/reject vouches and praise from other users
- Assign accepted items to specific rCard categories
- Mark notifications as read/unread
- Filter by status (pending, unread, all)
- Real-time badge count updates
- Professional notification UI with avatars and status chips

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
main
Oliver Sylvester-Bradley 2 months ago
parent f63a00a9f7
commit 142920b264
  1. 154
      src/components/layout/DashboardLayout.tsx
  2. 247
      src/components/notifications/NotificationDropdown.tsx
  3. 369
      src/components/notifications/NotificationItem.tsx
  4. 269
      src/services/notificationService.ts
  5. 146
      src/types/notification.ts

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
@ -26,7 +26,6 @@ import {
Menu as MenuIcon,
Settings,
Logout,
NotificationsNone,
SearchRounded,
Groups,
RssFeed,
@ -36,6 +35,9 @@ import {
Hub,
} from '@mui/icons-material';
import BottomNavigation from '../navigation/BottomNavigation';
import NotificationDropdown from '../notifications/NotificationDropdown';
import { notificationService } from '../../services/notificationService';
import type { Notification, NotificationSummary } from '../../types/notification';
const drawerWidth = 280;
@ -57,6 +59,13 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [profileMenuAnchor, setProfileMenuAnchor] = useState<null | HTMLElement>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['Network']));
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notificationSummary, setNotificationSummary] = useState<NotificationSummary>({
total: 0,
unread: 0,
pending: 0,
byType: { vouch: 0, praise: 0, connection: 0, group_invite: 0, message: 0, system: 0 }
});
const location = useLocation();
const navigate = useNavigate();
@ -67,6 +76,131 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
{ text: 'Chat', icon: <Chat />, path: '/messages' },
];
// Load notifications
useEffect(() => {
const loadNotifications = async () => {
try {
const [notificationData, summaryData] = await Promise.all([
notificationService.getNotifications('current-user'),
notificationService.getNotificationSummary('current-user')
]);
setNotifications(notificationData);
setNotificationSummary(summaryData);
} catch (error) {
console.error('Failed to load notifications:', error);
}
};
loadNotifications();
}, []);
// Notification handlers
const handleMarkAsRead = async (notificationId: string) => {
try {
await notificationService.markAsRead(notificationId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, isRead: true } : n)
);
setNotificationSummary(prev => ({
...prev,
unread: Math.max(0, prev.unread - 1)
}));
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
await notificationService.markAllAsRead('current-user');
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setNotificationSummary(prev => ({ ...prev, unread: 0 }));
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
}
};
const handleAcceptVouch = async (notificationId: string, vouchId: string) => {
try {
await notificationService.acceptVouch(notificationId, vouchId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, status: 'accepted', isActionable: true } : n)
);
setNotificationSummary(prev => ({
...prev,
pending: Math.max(0, prev.pending - 1)
}));
} catch (error) {
console.error('Failed to accept vouch:', error);
}
};
const handleRejectVouch = async (notificationId: string, vouchId: string) => {
try {
await notificationService.rejectVouch(notificationId, vouchId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, status: 'rejected', isActionable: false } : n)
);
setNotificationSummary(prev => ({
...prev,
pending: Math.max(0, prev.pending - 1)
}));
} catch (error) {
console.error('Failed to reject vouch:', error);
}
};
const handleAcceptPraise = async (notificationId: string, praiseId: string) => {
try {
await notificationService.acceptPraise(notificationId, praiseId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, status: 'accepted', isActionable: true } : n)
);
setNotificationSummary(prev => ({
...prev,
pending: Math.max(0, prev.pending - 1)
}));
} catch (error) {
console.error('Failed to accept praise:', error);
}
};
const handleRejectPraise = async (notificationId: string, praiseId: string) => {
try {
await notificationService.rejectPraise(notificationId, praiseId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, status: 'rejected', isActionable: false } : n)
);
setNotificationSummary(prev => ({
...prev,
pending: Math.max(0, prev.pending - 1)
}));
} catch (error) {
console.error('Failed to reject praise:', error);
}
};
const handleAssignToRCard = async (notificationId: string, rCardId: string) => {
try {
await notificationService.assignToRCard(notificationId, rCardId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? {
...n,
status: 'completed',
isActionable: false,
isRead: true,
metadata: { ...n.metadata, rCardId }
} : n)
);
setNotificationSummary(prev => ({
...prev,
unread: Math.max(0, prev.unread - 1)
}));
} catch (error) {
console.error('Failed to assign to rCard:', error);
}
};
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
@ -264,11 +398,17 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
<IconButton size="large" color="inherit">
<SearchRounded />
</IconButton>
<IconButton size="large" color="inherit">
<Badge badgeContent={3} color="error">
<NotificationsNone />
</Badge>
</IconButton>
<NotificationDropdown
notifications={notifications}
summary={notificationSummary}
onMarkAsRead={handleMarkAsRead}
onMarkAllAsRead={handleMarkAllAsRead}
onAcceptVouch={handleAcceptVouch}
onRejectVouch={handleRejectVouch}
onAcceptPraise={handleAcceptPraise}
onRejectPraise={handleRejectPraise}
onAssignToRCard={handleAssignToRCard}
/>
<IconButton
size="large"
edge="end"

@ -0,0 +1,247 @@
import { useState } from 'react';
import {
IconButton,
Badge,
Menu,
Box,
Typography,
List,
Divider,
Button,
Chip,
} from '@mui/material';
import {
NotificationsNone,
Notifications,
MarkEmailRead,
} from '@mui/icons-material';
import type { Notification, NotificationSummary } from '../../types/notification';
import NotificationItem from './NotificationItem';
interface NotificationDropdownProps {
notifications: Notification[];
summary: NotificationSummary;
onMarkAsRead: (notificationId: string) => void;
onMarkAllAsRead: () => void;
onAcceptVouch: (notificationId: string, vouchId: string) => void;
onRejectVouch: (notificationId: string, vouchId: string) => void;
onAcceptPraise: (notificationId: string, praiseId: string) => void;
onRejectPraise: (notificationId: string, praiseId: string) => void;
onAssignToRCard: (notificationId: string, rCardId: string) => void;
}
const NotificationDropdown = ({
notifications,
summary,
onMarkAsRead,
onMarkAllAsRead,
onAcceptVouch,
onRejectVouch,
onAcceptPraise,
onRejectPraise,
onAssignToRCard,
}: NotificationDropdownProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [filter, setFilter] = useState<'all' | 'pending' | 'unread'>('all');
const isOpen = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const filteredNotifications = notifications.filter(notification => {
switch (filter) {
case 'pending':
return notification.status === 'pending' && notification.isActionable;
case 'unread':
return !notification.isRead;
default:
return true;
}
});
const getFilterChipColor = (filterType: string) => {
return filter === filterType ? 'primary' : 'default';
};
return (
<>
<IconButton
size="large"
color="inherit"
onClick={handleClick}
aria-label="notifications"
aria-expanded={isOpen ? 'true' : undefined}
aria-haspopup="true"
>
<Badge badgeContent={summary.unread} color="error">
{summary.unread > 0 ? <Notifications /> : <NotificationsNone />}
</Badge>
</IconButton>
<Menu
anchorEl={anchorEl}
open={isOpen}
onClose={handleClose}
onClick={(e) => e.stopPropagation()}
PaperProps={{
elevation: 8,
sx: {
width: 400,
maxWidth: '90vw',
maxHeight: '80vh',
mt: 1.5,
borderRadius: 2,
border: 1,
borderColor: 'divider',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 20,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
border: 1,
borderColor: 'divider',
borderBottom: 0,
borderRight: 0,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
{/* Header */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Notifications
</Typography>
{summary.unread > 0 && (
<Button
size="small"
startIcon={<MarkEmailRead />}
onClick={onMarkAllAsRead}
sx={{ textTransform: 'none' }}
>
Mark all read
</Button>
)}
</Box>
{/* Summary Stats */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Chip
size="small"
label={`${summary.total} Total`}
sx={{ fontSize: '0.75rem' }}
/>
{summary.unread > 0 && (
<Chip
size="small"
label={`${summary.unread} Unread`}
color="error"
sx={{ fontSize: '0.75rem' }}
/>
)}
{summary.pending > 0 && (
<Chip
size="small"
label={`${summary.pending} Pending`}
color="warning"
sx={{ fontSize: '0.75rem' }}
/>
)}
</Box>
{/* Filter Chips */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
size="small"
label="All"
onClick={() => setFilter('all')}
color={getFilterChipColor('all')}
variant={filter === 'all' ? 'filled' : 'outlined'}
sx={{ fontSize: '0.75rem', cursor: 'pointer' }}
/>
<Chip
size="small"
label="Pending"
onClick={() => setFilter('pending')}
color={getFilterChipColor('pending')}
variant={filter === 'pending' ? 'filled' : 'outlined'}
sx={{ fontSize: '0.75rem', cursor: 'pointer' }}
/>
<Chip
size="small"
label="Unread"
onClick={() => setFilter('unread')}
color={getFilterChipColor('unread')}
variant={filter === 'unread' ? 'filled' : 'outlined'}
sx={{ fontSize: '0.75rem', cursor: 'pointer' }}
/>
</Box>
</Box>
{/* Notification List */}
<Box sx={{ maxHeight: 400, overflow: 'auto' }}>
{filteredNotifications.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{filter === 'all'
? 'No notifications yet'
: filter === 'pending'
? 'No pending notifications'
: 'No unread notifications'
}
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{filteredNotifications.map((notification, index) => (
<Box key={notification.id}>
<NotificationItem
notification={notification}
onMarkAsRead={onMarkAsRead}
onAcceptVouch={onAcceptVouch}
onRejectVouch={onRejectVouch}
onAcceptPraise={onAcceptPraise}
onRejectPraise={onRejectPraise}
onAssignToRCard={onAssignToRCard}
/>
{index < filteredNotifications.length - 1 && <Divider />}
</Box>
))}
</List>
)}
</Box>
{/* Footer */}
{summary.total > 0 && (
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', textAlign: 'center' }}>
<Button
variant="text"
size="small"
onClick={handleClose}
sx={{ textTransform: 'none' }}
>
View All Notifications
</Button>
</Box>
)}
</Menu>
</>
);
};
export default NotificationDropdown;

@ -0,0 +1,369 @@
import { useState } from 'react';
import {
ListItem,
Box,
Avatar,
Typography,
Button,
Chip,
IconButton,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
useTheme,
alpha,
} from '@mui/material';
import {
ThumbUp,
StarBorder,
CheckCircle,
Cancel,
Assignment,
MoreVert,
Business,
PersonOutline,
Groups,
FamilyRestroom,
Favorite,
Home,
} from '@mui/icons-material';
import type { Notification } from '../../types/notification';
import { DEFAULT_RCARDS } from '../../types/notification';
interface NotificationItemProps {
notification: Notification;
onMarkAsRead: (notificationId: string) => void;
onAcceptVouch: (notificationId: string, vouchId: string) => void;
onRejectVouch: (notificationId: string, vouchId: string) => void;
onAcceptPraise: (notificationId: string, praiseId: string) => void;
onRejectPraise: (notificationId: string, praiseId: string) => void;
onAssignToRCard: (notificationId: string, rCardId: string) => void;
}
const NotificationItem = ({
notification,
onMarkAsRead,
onAcceptVouch,
onRejectVouch,
onAcceptPraise,
onRejectPraise,
onAssignToRCard,
}: NotificationItemProps) => {
const theme = useTheme();
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [showAssignDialog, setShowAssignDialog] = useState(false);
const [selectedRCard, setSelectedRCard] = useState('');
const isMenuOpen = Boolean(menuAnchor);
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchor(null);
};
const handleAccept = () => {
if (notification.type === 'vouch' && notification.metadata?.vouchId) {
onAcceptVouch(notification.id, notification.metadata.vouchId);
} else if (notification.type === 'praise' && notification.metadata?.praiseId) {
onAcceptPraise(notification.id, notification.metadata.praiseId);
}
handleMenuClose();
};
const handleReject = () => {
if (notification.type === 'vouch' && notification.metadata?.vouchId) {
onRejectVouch(notification.id, notification.metadata.vouchId);
} else if (notification.type === 'praise' && notification.metadata?.praiseId) {
onRejectPraise(notification.id, notification.metadata.praiseId);
}
handleMenuClose();
};
const handleAssignClick = () => {
setShowAssignDialog(true);
handleMenuClose();
};
const handleAssignSubmit = () => {
if (selectedRCard) {
onAssignToRCard(notification.id, selectedRCard);
setShowAssignDialog(false);
setSelectedRCard('');
}
};
const handleMarkAsRead = () => {
onMarkAsRead(notification.id);
handleMenuClose();
};
const getNotificationIcon = () => {
switch (notification.type) {
case 'vouch':
return <ThumbUp sx={{ color: 'primary.main' }} />;
case 'praise':
return <StarBorder sx={{ color: 'warning.main' }} />;
default:
return null;
}
};
const getStatusChip = () => {
switch (notification.status) {
case 'pending':
return <Chip label="Pending" size="small" color="warning" />;
case 'accepted':
return <Chip label="Accepted" size="small" color="success" />;
case 'rejected':
return <Chip label="Declined" size="small" color="error" />;
case 'completed':
return <Chip label="Assigned" size="small" color="info" />;
default:
return null;
}
};
const getRCardIcon = (iconName: string) => {
switch (iconName) {
case 'Business':
return <Business />;
case 'PersonOutline':
return <PersonOutline />;
case 'Groups':
return <Groups />;
case 'FamilyRestroom':
return <FamilyRestroom />;
case 'Favorite':
return <Favorite />;
case 'Home':
return <Home />;
default:
return <PersonOutline />;
}
};
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
if (diffInMinutes < 1) return 'Just now';
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays}d ago`;
return date.toLocaleDateString();
};
return (
<>
<ListItem
sx={{
p: 2,
borderLeft: 4,
borderLeftColor: notification.isRead ? 'transparent' : 'primary.main',
backgroundColor: notification.isRead
? 'transparent'
: alpha(theme.palette.primary.main, 0.02),
'&:hover': {
backgroundColor: alpha(theme.palette.action.hover, 0.5),
},
}}
>
<Box sx={{ display: 'flex', width: '100%', gap: 2 }}>
{/* Avatar and Icon */}
<Box sx={{ position: 'relative' }}>
<Avatar
src={notification.fromUserAvatar}
alt={notification.fromUserName}
sx={{ width: 48, height: 48 }}
>
{notification.fromUserName?.charAt(0) || 'N'}
</Avatar>
{getNotificationIcon() && (
<Box
sx={{
position: 'absolute',
bottom: -4,
right: -4,
backgroundColor: 'background.paper',
borderRadius: '50%',
p: 0.5,
border: 2,
borderColor: 'background.paper',
}}
>
{getNotificationIcon()}
</Box>
)}
</Box>
{/* Content */}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, lineHeight: 1.2 }}>
{notification.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusChip()}
<IconButton size="small" onClick={handleMenuClick}>
<MoreVert />
</IconButton>
</Box>
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 1,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{notification.message}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
{formatTimeAgo(notification.createdAt)}
</Typography>
{/* Action Buttons */}
{notification.isActionable && notification.status === 'pending' && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<Cancel />}
onClick={handleReject}
sx={{ textTransform: 'none', minWidth: 'auto' }}
>
Decline
</Button>
<Button
size="small"
variant="contained"
startIcon={<CheckCircle />}
onClick={handleAccept}
sx={{ textTransform: 'none', minWidth: 'auto' }}
>
Accept
</Button>
</Box>
)}
{notification.status === 'accepted' && !notification.metadata?.rCardId && (
<Button
size="small"
variant="outlined"
startIcon={<Assignment />}
onClick={handleAssignClick}
sx={{ textTransform: 'none' }}
>
Assign to rCard
</Button>
)}
</Box>
</Box>
</Box>
</ListItem>
{/* Menu */}
<Menu
anchorEl={menuAnchor}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
sx: { minWidth: 160 }
}}
>
{!notification.isRead && (
<MenuItem onClick={handleMarkAsRead}>
Mark as read
</MenuItem>
)}
{notification.status === 'accepted' && !notification.metadata?.rCardId && (
<MenuItem onClick={handleAssignClick}>
Assign to rCard
</MenuItem>
)}
{notification.isActionable && notification.status === 'pending' && (
<>
<MenuItem onClick={handleAccept}>Accept</MenuItem>
<MenuItem onClick={handleReject}>Decline</MenuItem>
</>
)}
</Menu>
{/* Assign to rCard Dialog */}
<Dialog
open={showAssignDialog}
onClose={() => setShowAssignDialog(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
Assign to rCard
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Choose which rCard category to assign this {notification.type} to. This helps organize your connections and endorsements.
</Typography>
<FormControl fullWidth>
<InputLabel>Select rCard</InputLabel>
<Select
value={selectedRCard}
label="Select rCard"
onChange={(e) => setSelectedRCard(e.target.value)}
>
{DEFAULT_RCARDS.map((rCard, index) => (
<MenuItem key={index} value={`default-${index}`}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getRCardIcon(rCard.icon || 'PersonOutline')}
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{rCard.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{rCard.description}
</Typography>
</Box>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowAssignDialog(false)}>Cancel</Button>
<Button
onClick={handleAssignSubmit}
variant="contained"
disabled={!selectedRCard}
>
Assign
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default NotificationItem;

@ -0,0 +1,269 @@
import type {
Notification,
NotificationSummary,
Vouch,
Praise
} from '../types/notification';
// Mock data for development
const mockNotifications: Notification[] = [
{
id: '1',
type: 'vouch',
title: 'New Skill Vouch',
message: 'Sarah Johnson vouched for your React Development skills at Advanced level',
fromUserId: 'user-sarah',
fromUserName: 'Sarah Johnson',
fromUserAvatar: undefined,
targetUserId: 'current-user',
isRead: false,
isActionable: true,
status: 'pending',
metadata: {
vouchId: 'vouch-1',
},
createdAt: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago
updatedAt: new Date(Date.now() - 1000 * 60 * 30),
},
{
id: '2',
type: 'praise',
title: 'New Praise',
message: 'Mike Chen praised your leadership during the Q3 project launch',
fromUserId: 'user-mike',
fromUserName: 'Mike Chen',
fromUserAvatar: undefined,
targetUserId: 'current-user',
isRead: false,
isActionable: true,
status: 'pending',
metadata: {
praiseId: 'praise-1',
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: '3',
type: 'vouch',
title: 'Skill Vouch Accepted',
message: 'Your TypeScript vouch from Alex Rodriguez has been assigned to Business contacts',
fromUserId: 'user-alex',
fromUserName: 'Alex Rodriguez',
fromUserAvatar: undefined,
targetUserId: 'current-user',
isRead: true,
isActionable: false,
status: 'completed',
metadata: {
vouchId: 'vouch-2',
rCardId: 'rcard-business',
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 12), // Updated 12 hours ago
},
{
id: '4',
type: 'praise',
title: 'Teamwork Praise',
message: 'Jessica Liu praised your collaborative approach on the mobile app redesign',
fromUserId: 'user-jessica',
fromUserName: 'Jessica Liu',
fromUserAvatar: undefined,
targetUserId: 'current-user',
isRead: false,
isActionable: true,
status: 'accepted',
metadata: {
praiseId: 'praise-2',
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6), // 6 hours ago
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 4), // Updated 4 hours ago
},
];
const mockVouches: Vouch[] = [
{
id: 'vouch-1',
fromUserId: 'user-sarah',
fromUserName: 'Sarah Johnson',
fromUserAvatar: undefined,
toUserId: 'current-user',
skill: 'React Development',
description: 'Excellent component architecture and state management. Always writes clean, maintainable code.',
level: 'advanced',
endorsementText: 'I worked with this person on multiple React projects and they consistently delivered high-quality solutions.',
createdAt: new Date(Date.now() - 1000 * 60 * 30),
updatedAt: new Date(Date.now() - 1000 * 60 * 30),
},
{
id: 'vouch-2',
fromUserId: 'user-alex',
fromUserName: 'Alex Rodriguez',
fromUserAvatar: undefined,
toUserId: 'current-user',
skill: 'TypeScript',
description: 'Strong type safety practices and excellent knowledge of advanced TypeScript features.',
level: 'expert',
endorsementText: 'One of the best TypeScript developers I have worked with.',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
},
];
const mockPraises: Praise[] = [
{
id: 'praise-1',
fromUserId: 'user-mike',
fromUserName: 'Mike Chen',
fromUserAvatar: undefined,
toUserId: 'current-user',
category: 'leadership',
title: 'Outstanding Project Leadership',
description: 'Led the Q3 project launch with exceptional coordination and communication. Kept the team motivated and on track.',
tags: ['project-management', 'team-leadership', 'communication'],
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: 'praise-2',
fromUserId: 'user-jessica',
fromUserName: 'Jessica Liu',
fromUserAvatar: undefined,
toUserId: 'current-user',
category: 'teamwork',
title: 'Collaborative Team Player',
description: 'Always willing to help teammates and shares knowledge freely. Made the mobile app redesign a huge success.',
tags: ['collaboration', 'mobile-development', 'mentoring'],
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6),
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 6),
},
];
export class NotificationService {
private notifications: Notification[] = [...mockNotifications];
private vouches: Vouch[] = [...mockVouches];
private praises: Praise[] = [...mockPraises];
// Get all notifications for a user
async getNotifications(userId: string): Promise<Notification[]> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 300));
return this.notifications.filter(n => n.targetUserId === userId);
}
// Get notification summary
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
await new Promise(resolve => setTimeout(resolve, 100));
const userNotifications = this.notifications.filter(n => n.targetUserId === userId);
const summary: NotificationSummary = {
total: userNotifications.length,
unread: userNotifications.filter(n => !n.isRead).length,
pending: userNotifications.filter(n => n.status === 'pending' && n.isActionable).length,
byType: {
vouch: userNotifications.filter(n => n.type === 'vouch').length,
praise: userNotifications.filter(n => n.type === 'praise').length,
connection: userNotifications.filter(n => n.type === 'connection').length,
group_invite: userNotifications.filter(n => n.type === 'group_invite').length,
message: userNotifications.filter(n => n.type === 'message').length,
system: userNotifications.filter(n => n.type === 'system').length,
},
};
return summary;
}
// Mark notification as read
async markAsRead(notificationId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 200));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.isRead = true;
notification.updatedAt = new Date();
}
}
// Mark all notifications as read for a user
async markAllAsRead(userId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 300));
this.notifications
.filter(n => n.targetUserId === userId && !n.isRead)
.forEach(notification => {
notification.isRead = true;
notification.updatedAt = new Date();
});
}
// Accept a vouch
async acceptVouch(notificationId: string, _vouchId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 400));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.status = 'accepted';
notification.isActionable = true; // Still actionable for rCard assignment
notification.updatedAt = new Date();
}
}
// Reject a vouch
async rejectVouch(notificationId: string, _vouchId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 400));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.status = 'rejected';
notification.isActionable = false;
notification.updatedAt = new Date();
}
}
// Accept praise
async acceptPraise(notificationId: string, _praiseId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 400));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.status = 'accepted';
notification.isActionable = true; // Still actionable for rCard assignment
notification.updatedAt = new Date();
}
}
// Reject praise
async rejectPraise(notificationId: string, _praiseId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 400));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.status = 'rejected';
notification.isActionable = false;
notification.updatedAt = new Date();
}
}
// Assign to rCard
async assignToRCard(notificationId: string, rCardId: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 300));
const notification = this.notifications.find(n => n.id === notificationId);
if (notification && notification.metadata) {
notification.metadata.rCardId = rCardId;
notification.status = 'completed';
notification.isActionable = false;
notification.isRead = true;
notification.updatedAt = new Date();
}
}
// Get vouch details
async getVouch(vouchId: string): Promise<Vouch | null> {
await new Promise(resolve => setTimeout(resolve, 200));
return this.vouches.find(v => v.id === vouchId) || null;
}
// Get praise details
async getPraise(praiseId: string): Promise<Praise | null> {
await new Promise(resolve => setTimeout(resolve, 200));
return this.praises.find(p => p.id === praiseId) || null;
}
}
// Export singleton instance
export const notificationService = new NotificationService();

@ -0,0 +1,146 @@
export interface RCard {
id: string;
name: string;
description?: string;
color?: string;
icon?: string;
isDefault: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface Vouch {
id: string;
fromUserId: string;
fromUserName: string;
fromUserAvatar?: string;
toUserId: string;
skill: string;
description: string;
level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
endorsementText?: string;
createdAt: Date;
updatedAt: Date;
}
export interface Praise {
id: string;
fromUserId: string;
fromUserName: string;
fromUserAvatar?: string;
toUserId: string;
category: 'professional' | 'personal' | 'leadership' | 'teamwork' | 'communication' | 'creativity' | 'other';
title: string;
description: string;
tags?: string[];
createdAt: Date;
updatedAt: Date;
}
export interface NotificationAction {
id: string;
type: 'accept' | 'reject' | 'assign' | 'view';
label: string;
variant?: 'text' | 'outlined' | 'contained';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
}
export interface Notification {
id: string;
type: 'vouch' | 'praise' | 'connection' | 'group_invite' | 'message' | 'system';
title: string;
message: string;
fromUserId?: string;
fromUserName?: string;
fromUserAvatar?: string;
targetUserId: string;
isRead: boolean;
isActionable: boolean;
status: 'pending' | 'accepted' | 'rejected' | 'completed';
actions?: NotificationAction[];
metadata?: {
vouchId?: string;
praiseId?: string;
groupId?: string;
messageId?: string;
rCardId?: string;
};
createdAt: Date;
updatedAt: Date;
}
export interface VouchNotification extends Notification {
type: 'vouch';
metadata: {
vouchId: string;
rCardId?: string;
};
}
export interface PraiseNotification extends Notification {
type: 'praise';
metadata: {
praiseId: string;
rCardId?: string;
};
}
export interface NotificationSummary {
total: number;
unread: number;
pending: number;
byType: {
vouch: number;
praise: number;
connection: number;
group_invite: number;
message: number;
system: number;
};
}
// Default rCard categories
export const DEFAULT_RCARDS: Omit<RCard, 'id' | 'createdAt' | 'updatedAt'>[] = [
{
name: 'Business',
description: 'Professional business contacts and partnerships',
color: '#2563eb',
icon: 'Business',
isDefault: true,
},
{
name: 'Acquaintance',
description: 'People you know casually or have met briefly',
color: '#10b981',
icon: 'PersonOutline',
isDefault: true,
},
{
name: 'Colleague',
description: 'Current and former work colleagues',
color: '#8b5cf6',
icon: 'Groups',
isDefault: true,
},
{
name: 'Family',
description: 'Extended family members and relatives',
color: '#f59e0b',
icon: 'FamilyRestroom',
isDefault: true,
},
{
name: 'Friend',
description: 'Personal friends and social connections',
color: '#ef4444',
icon: 'Favorite',
isDefault: true,
},
{
name: 'Close Family',
description: 'Immediate family members',
color: '#ec4899',
icon: 'Home',
isDefault: true,
},
];
Loading…
Cancel
Save