- 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
parent
f63a00a9f7
commit
142920b264
@ -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…
Reference in new issue