Implement mobile-first account page redesign and navigation updates

- Rename navigation from Feed to Home with Dashboard icon
- Convert notifications from popup to dedicated page
- Restructure Account page tabs: Profile, My Cards, My Webpage, My Stream, My Bookmarks
- Fix mobile layout with edge-to-edge design and proper padding
- Remove scroll buttons from tabs to eliminate left margin gap
- Update My Bookmarks filters to display side-by-side on mobile

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

Co-Authored-By: Claude <noreply@anthropic.com>
main
Claude Code Assistant 2 months ago
parent f3097d7e3c
commit 530938ea6f
  1. 1
      000.env
  2. 21
      CLAUDE.md
  3. 2
      index.html
  4. 2
      package.json
  5. 6
      public/contacts.json
  6. 8
      src/App.css
  7. 2
      src/App.tsx
  8. 20
      src/components/account/MyCollectionPage.tsx
  9. 175
      src/components/layout/DashboardLayout.tsx
  10. 4
      src/components/navigation/BottomNavigation.tsx
  11. 6
      src/index.css
  12. 169
      src/pages/AccountPage.tsx
  13. 624
      src/pages/ContactListPage.tsx
  14. 372
      src/pages/ContactViewPage.tsx
  15. 56
      src/pages/FeedPage.tsx
  16. 1161
      src/pages/GroupDetailPage.tsx
  17. 55
      src/pages/GroupPage.tsx
  18. 415
      src/pages/NotificationsPage.tsx
  19. 6
      vite.config.ts

@ -0,0 +1 @@
VITE_ANTHROPIC_API_KEY=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

@ -0,0 +1,21 @@
# Project Permissions
- You have permission to read, write, and edit any files in this project
- No need to ask for approval before making code changes
- Focus on implementing solutions efficiently
# Development Commands
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run lint` - Run linting
- `npm run typecheck` - Run TypeScript checks
# Project Notes
This is a React/TypeScript NAO (Network of Authentic Others) community management application with features for:
- Contact management with NAO membership status
- Group management and invitations
- Vouch/praise system for reputation
- Network visualization
- Content feeds and messaging

@ -16,7 +16,7 @@
<!-- Theme and PWA -->
<meta name="theme-color" content="#1976d2" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NAO" />

@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"

@ -73,9 +73,10 @@
"company": "Wilson Consulting",
"position": "Business Consultant",
"source": "contacts",
"profileImage": "https://i.pravatar.cc/150?img=5",
"profileImage": "https://i.pravatar.cc/150?img=8",
"notes": "Helped with business strategy",
"tags": ["consulting", "strategy", "business"],
"naoStatus": "not_invited",
"createdAt": "2023-06-05T14:00:00Z",
"updatedAt": "2023-07-15T09:30:00Z"
},
@ -87,10 +88,11 @@
"company": "Marketing Co",
"position": "Marketing Director",
"source": "linkedin",
"profileImage": "https://i.pravatar.cc/150?img=6",
"profileImage": "https://i.pravatar.cc/150?img=9",
"linkedinUrl": "https://linkedin.com/in/lisathompson",
"notes": "Great at digital marketing strategies",
"tags": ["marketing", "digital", "strategy"],
"naoStatus": "not_invited",
"createdAt": "2023-05-12T12:00:00Z",
"updatedAt": "2023-06-20T15:45:00Z"
}

@ -1,8 +1,8 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}
.logo {

@ -16,6 +16,7 @@ import FeedPage from './pages/FeedPage';
import PostsOffersPage from './pages/PostsOffersPage';
import MessagesPage from './pages/MessagesPage';
import AccountPage from './pages/AccountPage';
import NotificationsPage from './pages/NotificationsPage';
import { createAppTheme } from './theme/theme';
const theme = createAppTheme('light');
@ -45,6 +46,7 @@ function App() {
<Route path="/groups/:groupId" element={<GroupDetailPage />} />
<Route path="/posts" element={<PostsOffersPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/invite" element={<InvitationPage />} />
<Route path="/onboarding/setup" element={<OnboardingPage />} />

@ -518,7 +518,7 @@ const MyCollectionPage = ({}: MyCollectionPageProps) => {
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
My Collection
My Bookmarks
</Typography>
<Button
variant="contained"
@ -549,7 +549,7 @@ const MyCollectionPage = ({}: MyCollectionPageProps) => {
{/* Filters */}
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, md: 4 }}>
<Grid size={{ xs: 6, md: 6 }}>
<FormControl fullWidth size="small">
<InputLabel>Collection</InputLabel>
<Select
@ -566,7 +566,7 @@ const MyCollectionPage = ({}: MyCollectionPageProps) => {
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Grid size={{ xs: 6, md: 6 }}>
<FormControl fullWidth size="small">
<InputLabel>Category</InputLabel>
<Select
@ -583,20 +583,6 @@ const MyCollectionPage = ({}: MyCollectionPageProps) => {
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort By</InputLabel>
<Select
value={sortBy}
label="Sort By"
onChange={(e) => setSortBy(e.target.value as any)}
>
<MenuItem value="recent">Recently Added</MenuItem>
<MenuItem value="title">Title</MenuItem>
<MenuItem value="author">Author</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Box>

@ -33,9 +33,10 @@ import {
ExpandLess,
ExpandMore,
Hub,
Dashboard,
Notifications,
} 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';
@ -59,7 +60,6 @@ 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,
@ -70,136 +70,26 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
const navigate = useNavigate();
const navItems: NavItem[] = [
{ text: 'Feed', icon: <RssFeed />, path: '/feed' },
{ text: 'Home', icon: <Dashboard />, path: '/feed' },
{ text: 'Network', icon: <Hub />, path: '/contacts' },
{ text: 'Groups', icon: <Groups />, path: '/groups' },
{ text: 'Chat', icon: <Chat />, path: '/messages' },
];
// Load notifications
// Load notification summary for badge
useEffect(() => {
const loadNotifications = async () => {
const loadNotificationSummary = async () => {
try {
const [notificationData, summaryData] = await Promise.all([
notificationService.getNotifications('current-user'),
notificationService.getNotificationSummary('current-user')
]);
setNotifications(notificationData);
const summaryData = await notificationService.getNotificationSummary('current-user');
setNotificationSummary(summaryData);
} catch (error) {
console.error('Failed to load notifications:', error);
console.error('Failed to load notification summary:', error);
}
};
loadNotifications();
loadNotificationSummary();
}, []);
// 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);
@ -331,7 +221,7 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
);
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundColor: '#ffffff' }}>
<AppBar
position="fixed"
sx={{
@ -346,15 +236,6 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
>
<Toolbar sx={{ justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
noWrap
@ -373,17 +254,15 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
<IconButton size="large" color="inherit">
<SearchRounded />
</IconButton>
<NotificationDropdown
notifications={notifications}
summary={notificationSummary}
onMarkAsRead={handleMarkAsRead}
onMarkAllAsRead={handleMarkAllAsRead}
onAcceptVouch={handleAcceptVouch}
onRejectVouch={handleRejectVouch}
onAcceptPraise={handleAcceptPraise}
onRejectPraise={handleRejectPraise}
onAssignToRCard={handleAssignToRCard}
/>
<IconButton
size="large"
color="inherit"
onClick={() => navigate('/notifications')}
>
<Badge badgeContent={notificationSummary.unread} color="error">
<Notifications />
</Badge>
</IconButton>
<IconButton
size="large"
edge="end"
@ -455,17 +334,23 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
component="main"
sx={{
flexGrow: 1,
width: { md: `calc(100% - ${drawerWidth}px)` },
width: { xs: '100%', md: `calc(100% - ${drawerWidth}px)` },
minHeight: '100vh',
backgroundColor: 'background.default',
backgroundColor: '#ffffff', // Always white background
overflow: 'hidden', // Prevent any overflow
maxWidth: '100vw' // Never exceed viewport
}}
>
<Toolbar />
<Box sx={{
p: { xs: 2, md: 3 },
height: 'calc(100vh - 64px)',
overflow: 'auto',
pb: { xs: 10, md: 3 }
p: { xs: 0, md: 3 },
minHeight: 'calc(100vh - 64px)',
overflow: 'visible',
pb: { xs: 10, md: 3 },
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
backgroundColor: '#ffffff' // Always white background
}}>
{children}
</Box>

@ -5,7 +5,7 @@ import {
Paper
} from '@mui/material';
import {
RssFeed,
Dashboard,
Hub,
Chat,
Groups,
@ -16,7 +16,7 @@ const BottomNavigation = () => {
const navigate = useNavigate();
const navigationItems = [
{ label: 'Feed', icon: <RssFeed />, path: '/feed' },
{ label: 'Home', icon: <Dashboard />, path: '/feed' },
{ label: 'Network', icon: <Hub />, path: '/contacts' },
{ label: 'Groups', icon: <Groups />, path: '/groups' },
{ label: 'Chat', icon: <Chat />, path: '/messages' },

@ -3,9 +3,9 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color-scheme: light;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;

@ -29,6 +29,10 @@ import {
Settings,
Dashboard,
BookmarkBorder,
RssFeed,
Bookmarks,
Language,
Timeline,
} from '@mui/icons-material';
import { DEFAULT_RCARDS, DEFAULT_PRIVACY_SETTINGS } from '../types/notification';
import type { RCardWithPrivacy } from '../types/notification';
@ -48,7 +52,7 @@ interface TabPanelProps {
const TabPanel = ({ children, value, index }: TabPanelProps) => {
return (
<div hidden={value !== index}>
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
{value === index && <Box sx={{ p: { xs: 0, md: 3 } }}>{children}</Box>}
</div>
);
};
@ -242,74 +246,108 @@ const AccountPage = () => {
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{
height: '100%',
width: '100%',
maxWidth: { xs: '100vw', md: '100%' },
overflow: 'hidden',
boxSizing: 'border-box'
}}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
<Box sx={{
mb: { xs: 2, md: 3 },
width: '100%',
overflow: 'hidden',
minWidth: 0,
p: { xs: '10px', md: 0 }
}}>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: '1.5rem', md: '2.125rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
My Account
</Typography>
<Typography variant="body1" color="text.secondary">
Manage your profile, privacy settings, and relationship cards (rCards)
</Typography>
</Box>
{/* Navigation Tabs */}
<Paper sx={{ mb: 3 }}>
<Paper sx={{
mb: { xs: 0, md: 3 },
width: '100%',
overflow: 'hidden',
borderRadius: { xs: 0, md: 1 },
boxSizing: 'border-box',
mx: { xs: 0, md: 'auto' }
}}>
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="scrollable"
scrollButtons={false}
sx={{
borderBottom: 1,
borderColor: 'divider',
minHeight: 56,
'& .MuiTabs-scrollButtons': {
display: 'none'
},
'& .MuiTab-root': {
minHeight: 64,
minHeight: 56,
textTransform: 'none',
fontWeight: 500,
minWidth: { xs: 80, sm: 120 },
fontSize: { xs: '0.75rem', sm: '0.875rem' }
}
}}
>
<Tab icon={<Person />} label="Profile" />
<Tab icon={<Security />} label="My Cards" />
<Tab icon={<Dashboard />} label="My Home" />
<Tab icon={<BookmarkBorder />} label="My Collection" />
<Tab icon={<Settings />} label="Account Settings" />
<Tab icon={<Language />} label="My Webpage" />
<Tab icon={<Timeline />} label="My Stream" />
<Tab icon={<Bookmarks />} label="My Bookmarks" />
</Tabs>
{/* Profile Tab */}
<TabPanel value={tabValue} index={0}>
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: '10px', md: 0 } }}>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 4 }}>
<Card>
<CardContent sx={{ textAlign: 'center', p: 4 }}>
<Avatar
sx={{
width: 120,
height: 120,
mx: 'auto',
mb: 2,
bgcolor: 'primary.main',
fontSize: '3rem'
}}
alt="Profile"
src="/static/images/avatar/2.jpg"
>
J
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1 }}>
John Doe
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Product Manager at TechCorp
</Typography>
<Button variant="outlined" startIcon={<Edit />}>
Edit Profile
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Grid size={{ xs: 12, md: 4 }}>
<Card>
<CardContent sx={{ textAlign: 'center', p: 4 }}>
<Avatar
sx={{
width: 120,
height: 120,
mx: 'auto',
mb: 2,
bgcolor: 'primary.main',
fontSize: '3rem'
}}
alt="Profile"
src="/static/images/avatar/2.jpg"
>
J
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1 }}>
John Doe
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Product Manager at TechCorp
</Typography>
<Button variant="outlined" startIcon={<Edit />}>
Edit Profile
</Button>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Card>
<CardContent sx={{ p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
@ -354,7 +392,7 @@ const AccountPage = () => {
{/* My Cards Tab */}
<TabPanel value={tabValue} index={1}>
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: '10px', md: 0 } }}>
<Grid container spacing={3}>
{/* rCard List */}
<Grid size={{ xs: 12, md: 4 }}>
@ -460,33 +498,36 @@ const AccountPage = () => {
</Box>
</TabPanel>
{/* My Home Tab */}
{/* My Webpage Tab */}
<TabPanel value={tabValue} index={2}>
<Box sx={{ p: 3 }}>
<MyHomePage />
<Box sx={{ p: { xs: '10px', md: 0 } }}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
My Webpage
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Customizable web page, where you choose what to show - maybe 3 blocks e.g widgets, possibly including the last 5 things from your stream.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Coming soon...
</Typography>
</CardContent>
</Card>
</Box>
</TabPanel>
{/* My Collection Tab */}
{/* My Stream Tab */}
<TabPanel value={tabValue} index={3}>
<Box sx={{ p: 3 }}>
<MyCollectionPage />
<Box sx={{ p: { xs: '10px', md: 0 } }}>
<MyHomePage />
</Box>
</TabPanel>
{/* Account Settings Tab */}
{/* My Bookmarks Tab */}
<TabPanel value={tabValue} index={4}>
<Box sx={{ p: 3 }}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Account Settings
</Typography>
<Typography variant="body2" color="text.secondary">
Account settings coming soon...
</Typography>
</CardContent>
</Card>
<Box sx={{ p: { xs: '10px', md: 0 } }}>
<MyCollectionPage />
</Box>
</TabPanel>
</Paper>
@ -499,7 +540,7 @@ const AccountPage = () => {
onDelete={handleRCardDelete}
editingRCard={editingRCard || undefined}
/>
</Container>
</Box>
);
};

@ -32,7 +32,9 @@ import {
Favorite,
CheckCircle,
Schedule,
Send
Send,
ArrowUpward,
ArrowDownward
} from '@mui/icons-material';
import { dataService } from '../services/dataService';
import type { Contact } from '../types/contact';
@ -152,11 +154,79 @@ const ContactListPage = () => {
navigate(`/invite?inviteeName=${encodeURIComponent(contact.name)}&inviteeEmail=${encodeURIComponent(contact.email)}`);
};
const getVouchPraiseCounts = (contact: Contact) => {
// Based on the contact detail page data, return appropriate counts
// This would normally come from a real data service, but for demo purposes:
if (contact.naoStatus === 'member') {
// For NAO members, show sent/received counts
if (contact.name === 'Sarah Johnson') {
return {
vouchesSent: 1, // What I sent to Sarah
vouchesReceived: 2, // What Sarah sent to me
praisesSent: 2, // What I sent to Sarah
praisesReceived: 1 // What Sarah sent to me
};
} else if (contact.name === 'Emily Rodriguez') {
return {
vouchesSent: 1,
vouchesReceived: 2,
praisesSent: 2,
praisesReceived: 1
};
}
} else {
// For non-members, show what I've sent (hidden from them)
return {
vouchesSent: 1, // What I sent to them (hidden)
vouchesReceived: 0, // They can't send until they join
praisesSent: 1, // What I sent to them (hidden)
praisesReceived: 0 // They can't send until they join
};
}
return {
vouchesSent: 0,
vouchesReceived: 0,
praisesSent: 0,
praisesReceived: 0
};
};
return (
<Box sx={{ height: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
<Box sx={{
height: '100%',
width: '100%',
maxWidth: { xs: '100vw', md: '100%' },
overflow: 'hidden',
boxSizing: 'border-box',
p: { xs: '10px', md: 0 },
mx: { xs: 0, md: 'auto' }
}}>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'stretch', md: 'center' },
mb: { xs: 2, md: 3 },
gap: { xs: 2, md: 0 },
width: '100%',
overflow: 'hidden',
minWidth: 0
}}>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: 700,
mb: 1,
fontSize: { xs: '1.5rem', md: '2.125rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{isSelectionMode ? 'Select Contact to Invite' : 'Contacts'}
</Typography>
{isSelectionMode && (
@ -166,12 +236,27 @@ const ContactListPage = () => {
)}
</Box>
{!isSelectionMode && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{
display: 'flex',
gap: { xs: 1, md: 1 },
flexDirection: { xs: 'row', md: 'row' },
flexWrap: 'wrap',
width: { xs: '100%', md: 'auto' },
justifyContent: { xs: 'space-between', md: 'flex-end' }
}}>
<Button
variant="outlined"
startIcon={<CloudDownload />}
onClick={() => navigate('/import')}
sx={{ borderRadius: 2 }}
sx={{
borderRadius: 2,
flex: { xs: 1, md: 'none' },
minWidth: 0,
fontSize: { xs: '0.75rem', md: '0.875rem' },
px: { xs: 0.5, md: 2 },
py: { xs: 0.5, md: 1 },
maxWidth: { xs: 'calc(33.33% - 4px)', md: 'none' }
}}
>
Import
</Button>
@ -179,7 +264,15 @@ const ContactListPage = () => {
variant="outlined"
startIcon={<QrCode />}
onClick={handleInvite}
sx={{ borderRadius: 2 }}
sx={{
borderRadius: 2,
flex: { xs: 1, md: 'none' },
minWidth: 0,
fontSize: { xs: '0.75rem', md: '0.875rem' },
px: { xs: 0.5, md: 2 },
py: { xs: 0.5, md: 1 },
maxWidth: { xs: 'calc(33.33% - 4px)', md: 'none' }
}}
>
Invite
</Button>
@ -187,19 +280,44 @@ const ContactListPage = () => {
variant="contained"
startIcon={<Add />}
onClick={handleAddContact}
sx={{ borderRadius: 2 }}
sx={{
borderRadius: 2,
flex: { xs: 1, md: 'none' },
minWidth: 0,
fontSize: { xs: '0.75rem', md: '0.875rem' },
px: { xs: 0.5, md: 2 },
py: { xs: 0.5, md: 1 },
maxWidth: { xs: 'calc(33.33% - 4px)', md: 'none' }
}}
>
Add Contacts
{/* Shorter text on mobile */}
<Box component="span" sx={{ display: { xs: 'none', md: 'inline' } }}>
Add Contacts
</Box>
<Box component="span" sx={{ display: { xs: 'inline', md: 'none' } }}>
Add
</Box>
</Button>
</Box>
)}
</Box>
<Card sx={{ mb: 3 }}>
<Card sx={{
mb: 3,
width: { xs: 'calc(100% + 20px)', md: '100%' },
maxWidth: { xs: 'calc(100vw - 0px)', md: '100%' },
overflow: 'hidden',
mx: { xs: '-10px', md: 0 }, // Extend to edges on mobile
borderRadius: { xs: 0, md: 1 }, // Remove border radius on mobile
boxSizing: 'border-box'
}}>
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="contact view tabs"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
borderBottom: 1,
borderColor: 'divider',
@ -207,6 +325,8 @@ const ContactListPage = () => {
minHeight: 56,
textTransform: 'none',
fontWeight: 500,
minWidth: { xs: 80, sm: 120 },
fontSize: { xs: '0.75rem', sm: '0.875rem' }
}
}}
>
@ -223,7 +343,7 @@ const ContactListPage = () => {
</Tooltip>
</Tabs>
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: '10px', md: 3 } }}>
<TextField
fullWidth
placeholder="Search contacts..."
@ -275,36 +395,273 @@ const ContactListPage = () => {
} : {},
}}
>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
backgroundImage: contact.profileImage ? `url(${contact.profileImage})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: contact.profileImage ? 'transparent' : 'primary.main',
color: 'white',
fontSize: '1.5rem',
fontWeight: 'bold',
flexShrink: 0
}}
>
{!contact.profileImage && contact.name.charAt(0)}
</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{contact.name}
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
alignItems: { xs: 'stretch', md: 'center' },
gap: { xs: 2, md: 2 }
}}>
{/* Top row on mobile: Avatar + Name + Action Button */}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
width: '100%'
}}>
<Box
sx={{
width: { xs: 48, md: 64 },
height: { xs: 48, md: 64 },
borderRadius: '50%',
backgroundImage: contact.profileImage ? `url(${contact.profileImage})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: contact.profileImage ? 'transparent' : 'primary.main',
color: 'white',
fontSize: { xs: '1.2rem', md: '1.5rem' },
fontWeight: 'bold',
flexShrink: 0
}}
>
{!contact.profileImage && contact.name.charAt(0)}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="h6"
component="div"
sx={{
fontWeight: 600,
fontSize: { xs: '1rem', md: '1.25rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexGrow: 1
}}
>
{contact.name}
</Typography>
{getSourceIcon(contact.source)}
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.75rem', md: '0.875rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{contact.position} {contact.company && `at ${contact.company}`}
</Typography>
{getSourceIcon(contact.source)}
</Box>
{/* Action buttons - always visible on the right */}
<Box sx={{ flexShrink: 0 }}>
{/* Select button for selection mode */}
{isSelectionMode && (
<Button
variant="contained"
onClick={(e) => {
e.stopPropagation();
handleSelectContact(contact);
}}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: { xs: 60, md: 80 },
fontSize: { xs: '0.75rem', md: '0.875rem' }
}}
>
Select
</Button>
)}
{/* Invite to NAO button for non-members (not in selection mode) */}
{!isSelectionMode && contact.naoStatus === 'not_invited' && (
<Button
variant="outlined"
startIcon={<Send sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
handleInviteToNao(contact);
}}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: { xs: 60, md: 80 },
fontSize: { xs: '0.75rem', md: '0.875rem' },
py: 0.5,
px: { xs: 1, md: 1.5 }
}}
>
Invite
</Button>
)}
</Box>
</Box>
{/* Bottom section: Email + Status chips + Tags */}
<Box sx={{
width: '100%',
display: { xs: 'block', md: 'none' } // Only show on mobile
}}>
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 1,
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{contact.email}
</Typography>
{/* Compact status and metrics chips */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{contact.groupIds && contact.groupIds.length > 0 && (
<Chip
icon={<Group sx={{ fontSize: 12 }} />}
label={contact.groupIds.length}
size="small"
variant="outlined"
sx={{
fontSize: '0.65rem',
height: 18,
borderRadius: 1,
backgroundColor: alpha(theme.palette.success.main, 0.04),
borderColor: alpha(theme.palette.success.main, 0.12),
color: 'success.main',
'& .MuiChip-icon': { fontSize: 12 },
}}
/>
)}
{/* NAO Status Indicator */}
{(() => {
const naoStatus = getNaoStatusIndicator(contact);
return naoStatus ? (
<Chip
icon={naoStatus.icon}
label={naoStatus.label}
size="small"
variant="outlined"
sx={{
fontSize: '0.65rem',
height: 18,
borderRadius: 1,
backgroundColor: naoStatus.bgColor,
borderColor: naoStatus.borderColor,
color: naoStatus.color,
'& .MuiChip-icon': { fontSize: 12 },
}}
/>
) : null;
})()}
{/* Simplified Vouch and Praise counts */}
{(() => {
const counts = getVouchPraiseCounts(contact);
const totalVouches = counts.vouchesSent + counts.vouchesReceived;
const totalPraises = counts.praisesSent + counts.praisesReceived;
return (
<>
{totalVouches > 0 && (
<Chip
icon={<VerifiedUser sx={{ fontSize: 12 }} />}
label={totalVouches}
size="small"
variant="outlined"
sx={{
fontSize: '0.65rem',
height: 18,
borderRadius: 1,
backgroundColor: alpha(theme.palette.primary.main, 0.04),
borderColor: alpha(theme.palette.primary.main, 0.12),
color: 'primary.main',
'& .MuiChip-icon': { fontSize: 12 },
}}
/>
)}
{totalPraises > 0 && (
<Chip
icon={<Favorite sx={{ fontSize: 12 }} />}
label={totalPraises}
size="small"
variant="outlined"
sx={{
fontSize: '0.65rem',
height: 18,
borderRadius: 1,
backgroundColor: alpha('#f8bbd9', 0.3),
borderColor: alpha('#d81b60', 0.3),
color: '#d81b60',
'& .MuiChip-icon': { fontSize: 12 },
}}
/>
)}
</>
);
})()}
</Box>
{/* Tags - only show first 2 on mobile */}
{contact.tags && contact.tags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{contact.tags.slice(0, 2).map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
variant="outlined"
sx={{
borderRadius: 1,
backgroundColor: alpha(theme.palette.primary.main, 0.04),
borderColor: alpha(theme.palette.primary.main, 0.12),
color: 'primary.main',
fontWeight: 500,
fontSize: '0.65rem',
height: 18
}}
/>
))}
{contact.tags.length > 2 && (
<Chip
label={`+${contact.tags.length - 2}`}
size="small"
variant="outlined"
sx={{
borderRadius: 1,
backgroundColor: alpha(theme.palette.grey[500], 0.04),
borderColor: alpha(theme.palette.grey[500], 0.12),
color: 'text.secondary',
fontSize: '0.65rem',
height: 18
}}
/>
)}
</Box>
)}
</Box>
{/* Desktop layout (hidden on mobile) */}
<Box sx={{
flexGrow: 1,
display: { xs: 'none', md: 'block' } // Only show on desktop
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
{contact.groupIds && contact.groupIds.length > 0 && (
<Chip
icon={<Group sx={{ fontSize: 14 }} />}
@ -318,9 +675,7 @@ const ContactListPage = () => {
backgroundColor: alpha(theme.palette.success.main, 0.04),
borderColor: alpha(theme.palette.success.main, 0.12),
color: 'success.main',
'& .MuiChip-icon': {
fontSize: 14,
},
'& .MuiChip-icon': { fontSize: 14 },
}}
/>
)}
@ -341,58 +696,108 @@ const ContactListPage = () => {
backgroundColor: naoStatus.bgColor,
borderColor: naoStatus.borderColor,
color: naoStatus.color,
'& .MuiChip-icon': {
fontSize: 14,
},
'& .MuiChip-icon': { fontSize: 14 },
}}
/>
) : null;
})()}
{/* Vouch and Praise Indicators - Show received vouches/praises for everyone */}
<Chip
icon={<VerifiedUser sx={{ fontSize: 14 }} />}
label="2"
size="small"
variant="outlined"
title={contact.naoStatus === 'member' ? 'Vouches given and received' : 'Vouches received from NAO members'}
sx={{
fontSize: '0.75rem',
height: 20,
borderRadius: 1,
backgroundColor: alpha(theme.palette.primary.main, 0.04),
borderColor: alpha(theme.palette.primary.main, 0.12),
color: 'primary.main',
'& .MuiChip-icon': {
fontSize: 14,
},
}}
/>
<Chip
icon={<Favorite sx={{ fontSize: 14 }} />}
label="3"
size="small"
variant="outlined"
title={contact.naoStatus === 'member' ? 'Praises given and received' : 'Praises received from NAO members'}
sx={{
fontSize: '0.75rem',
height: 20,
borderRadius: 1,
backgroundColor: alpha('#f8bbd9', 0.3),
borderColor: alpha('#d81b60', 0.3),
color: '#d81b60',
'& .MuiChip-icon': {
fontSize: 14,
},
}}
/>
{/* Vouch and Praise Indicators with directional arrows - desktop only */}
{(() => {
const counts = getVouchPraiseCounts(contact);
const totalVouches = counts.vouchesSent + counts.vouchesReceived;
const totalPraises = counts.praisesSent + counts.praisesReceived;
return (
<>
<Chip
icon={<VerifiedUser sx={{ fontSize: 14 }} />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{counts.vouchesReceived > 0 && (
<>
<ArrowDownward sx={{ fontSize: 10 }} />
<span>{counts.vouchesReceived}</span>
</>
)}
{counts.vouchesSent > 0 && counts.vouchesReceived > 0 && (
<span style={{ fontSize: '0.6rem', opacity: 0.6 }}></span>
)}
{counts.vouchesSent > 0 && (
<>
<ArrowUpward sx={{ fontSize: 10 }} />
<span>{counts.vouchesSent}</span>
</>
)}
{totalVouches === 0 && <span>0</span>}
</Box>
}
size="small"
variant="outlined"
title={contact.naoStatus === 'member'
? `Vouches: ${counts.vouchesReceived} received, ${counts.vouchesSent} sent`
: `Vouches: ${counts.vouchesSent} sent (hidden until they join)`}
sx={{
fontSize: '0.75rem',
height: 20,
borderRadius: 1,
backgroundColor: alpha(theme.palette.primary.main, 0.04),
borderColor: alpha(theme.palette.primary.main, 0.12),
color: 'primary.main',
'& .MuiChip-icon': { fontSize: 14 },
'& .MuiChip-label': {
fontSize: '0.75rem',
padding: '0 4px',
},
}}
/>
<Chip
icon={<Favorite sx={{ fontSize: 14 }} />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{counts.praisesReceived > 0 && (
<>
<ArrowDownward sx={{ fontSize: 10 }} />
<span>{counts.praisesReceived}</span>
</>
)}
{counts.praisesSent > 0 && counts.praisesReceived > 0 && (
<span style={{ fontSize: '0.6rem', opacity: 0.6 }}></span>
)}
{counts.praisesSent > 0 && (
<>
<ArrowUpward sx={{ fontSize: 10 }} />
<span>{counts.praisesSent}</span>
</>
)}
{totalPraises === 0 && <span>0</span>}
</Box>
}
size="small"
variant="outlined"
title={contact.naoStatus === 'member'
? `Praises: ${counts.praisesReceived} received, ${counts.praisesSent} sent`
: `Praises: ${counts.praisesSent} sent (hidden until they join)`}
sx={{
fontSize: '0.75rem',
height: 20,
borderRadius: 1,
backgroundColor: alpha('#f8bbd9', 0.3),
borderColor: alpha('#d81b60', 0.3),
color: '#d81b60',
'& .MuiChip-icon': { fontSize: 14 },
'& .MuiChip-label': {
fontSize: '0.75rem',
padding: '0 4px',
},
}}
/>
</>
);
})()}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{contact.position} {contact.company && `at ${contact.company}`}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{contact.email}
</Typography>
@ -418,49 +823,6 @@ const ContactListPage = () => {
Added {formatDate(contact.createdAt)}
</Typography>
</Box>
{/* Action buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignSelf: 'center' }}>
{/* Select button for selection mode */}
{isSelectionMode && (
<Button
variant="contained"
onClick={(e) => {
e.stopPropagation();
handleSelectContact(contact);
}}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 80
}}
>
Select
</Button>
)}
{/* Invite to NAO button for non-members (not in selection mode) */}
{!isSelectionMode && contact.naoStatus === 'not_invited' && (
<Button
variant="outlined"
startIcon={<Send sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
handleInviteToNao(contact);
}}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 80,
fontSize: '0.875rem',
py: 0.5,
px: 1.5
}}
>
Invite
</Button>
)}
</Box>
</Box>
</CardContent>
</Card>

@ -28,7 +28,9 @@ import {
Add,
Send,
VerifiedUser,
Favorite
Favorite,
CheckCircle,
PersonOutline
} from '@mui/icons-material';
import { dataService } from '../services/dataService';
import type { Contact } from '../types/contact';
@ -85,6 +87,44 @@ const ContactViewPage = () => {
navigate('/contacts');
};
const handleInviteToNao = () => {
if (contact) {
navigate(`/invite?inviteeName=${encodeURIComponent(contact.name)}&inviteeEmail=${encodeURIComponent(contact.email)}`);
}
};
const getNaoStatusIndicator = (contact: Contact) => {
switch (contact.naoStatus) {
case 'member':
return {
icon: <CheckCircle sx={{ fontSize: 20 }} />,
label: 'NAO Member',
color: theme.palette.success.main,
bgColor: alpha(theme.palette.success.main, 0.08),
borderColor: alpha(theme.palette.success.main, 0.2),
description: 'This person is already a member of the NAO network'
};
case 'invited':
return {
icon: <Schedule sx={{ fontSize: 20 }} />,
label: 'Invited to NAO',
color: theme.palette.warning.main,
bgColor: alpha(theme.palette.warning.main, 0.08),
borderColor: alpha(theme.palette.warning.main, 0.2),
description: 'This person has been invited to NAO but hasn\'t joined yet'
};
default:
return {
icon: <PersonOutline sx={{ fontSize: 20 }} />,
label: 'Not on NAO',
color: theme.palette.text.secondary,
bgColor: alpha(theme.palette.grey[500], 0.08),
borderColor: alpha(theme.palette.grey[500], 0.2),
description: 'This person is not yet part of the NAO network'
};
}
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
@ -217,13 +257,31 @@ const ContactViewPage = () => {
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<Chip
icon={sourceDetails.icon}
label={`From ${sourceDetails.name}`}
variant="outlined"
sx={{ color: sourceDetails.color, borderColor: sourceDetails.color }}
/>
{/* NAO Status Indicator */}
{(() => {
const naoStatus = getNaoStatusIndicator(contact);
return (
<Chip
icon={naoStatus.icon}
label={naoStatus.label}
variant="outlined"
sx={{
backgroundColor: naoStatus.bgColor,
borderColor: naoStatus.borderColor,
color: naoStatus.color,
fontWeight: 500
}}
/>
);
})()}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
@ -248,7 +306,7 @@ const ContactViewPage = () => {
/>
</Box>
{/* Vouch and Praise Actions */}
{/* Action Buttons */}
<Box sx={{
display: 'flex',
gap: 1,
@ -256,6 +314,20 @@ const ContactViewPage = () => {
justifyContent: { xs: 'center', sm: 'flex-start' },
mt: 2
}}>
{/* Invite to NAO button for non-members */}
{contact.naoStatus === 'not_invited' && (
<Button
variant="contained"
startIcon={<Send />}
size="small"
onClick={handleInviteToNao}
color="primary"
>
Invite to NAO
</Button>
)}
{/* Vouch and Praise buttons */}
<Button
variant="contained"
startIcon={<VerifiedUser />}
@ -394,6 +466,45 @@ const ContactViewPage = () => {
</Box>
</Box>
{/* NAO Status Details */}
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
NAO Network Status
</Typography>
{(() => {
const naoStatus = getNaoStatusIndicator(contact);
return (
<Box sx={{
p: 2,
borderRadius: 2,
backgroundColor: naoStatus.bgColor,
border: 1,
borderColor: naoStatus.borderColor
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
{naoStatus.icon}
<Typography variant="body2" sx={{ fontWeight: 600, color: naoStatus.color }}>
{naoStatus.label}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{naoStatus.description}
</Typography>
{contact.naoStatus === 'member' && contact.joinedAt && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Joined {formatDate(contact.joinedAt)}
</Typography>
)}
{contact.naoStatus === 'invited' && contact.invitedAt && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Invited {formatDate(contact.invitedAt)}
</Typography>
)}
</Box>
);
})()}
</Box>
{contact.notes && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
@ -463,52 +574,144 @@ const ContactViewPage = () => {
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Vouch item */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>React Development</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
{contact.naoStatus === 'member' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Vouch item */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>React Development</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Exceptional React skills and clean code practices."
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Exceptional React skills and clean code practices."
</Typography>
</Box>
</Box>
{/* Praise items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Leadership</Typography>
<Typography variant="caption" color="text.secondary"> 3 days ago</Typography>
{/* Praise items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Leadership</Typography>
<Typography variant="caption" color="text.secondary"> 3 days ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Great leadership during project crunch time!"
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Communication</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Always clear and helpful in discussions."
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Great leadership during project crunch time!"
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Communication</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Show example vouches/praises sent to non-members */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2, position: 'relative' }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{contact.name === 'John Smith' ? 'JavaScript Expertise' :
contact.name === 'David Wilson' ? 'Strategic Thinking' :
contact.name === 'Lisa Thompson' ? 'Digital Marketing' : 'Professional Skills'}
</Typography>
<Typography variant="caption" color="text.secondary"> 3 days ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{contact.name === 'John Smith' ? '"Excellent JavaScript skills and problem-solving approach."' :
contact.name === 'David Wilson' ? '"Great strategic insights during our consulting sessions."' :
contact.name === 'Lisa Thompson' ? '"Outstanding digital marketing campaigns and results."' : '"Great professional skills and expertise."'}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Always clear and helpful in discussions."
<Chip
label="Hidden"
size="small"
variant="outlined"
sx={{
position: 'absolute',
top: 8,
right: 8,
fontSize: '0.7rem',
height: 20,
backgroundColor: alpha(theme.palette.warning.main, 0.08),
borderColor: alpha(theme.palette.warning.main, 0.3),
color: 'warning.main'
}}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2, position: 'relative' }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{contact.name === 'John Smith' ? 'Collaboration' :
contact.name === 'David Wilson' ? 'Mentorship' :
contact.name === 'Lisa Thompson' ? 'Creativity' : 'Teamwork'}
</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{contact.name === 'John Smith' ? '"Always helpful and great to work with on projects."' :
contact.name === 'David Wilson' ? '"Provided valuable guidance and mentorship."' :
contact.name === 'Lisa Thompson' ? '"Creative approach to marketing challenges."' : '"Excellent team collaboration and support."'}
</Typography>
</Box>
<Chip
label="Hidden"
size="small"
variant="outlined"
sx={{
position: 'absolute',
top: 8,
right: 8,
fontSize: '0.7rem',
height: 20,
backgroundColor: alpha(theme.palette.warning.main, 0.08),
borderColor: alpha(theme.palette.warning.main, 0.3),
color: 'warning.main'
}}
/>
</Box>
<Box sx={{
mt: 2,
p: 2,
bgcolor: alpha(theme.palette.info.main, 0.04),
borderRadius: 2,
border: 1,
borderColor: alpha(theme.palette.info.main, 0.12)
}}>
<Typography variant="body2" color="info.main" sx={{ fontWeight: 500, mb: 0.5 }}>
Note: Hidden from {contact.name.split(' ')[0]}
</Typography>
<Typography variant="caption" color="text.secondary">
{contact.naoStatus === 'invited'
? `These vouches and praises will become visible to ${contact.name.split(' ')[0]} once they accept their NAO invitation.`
: `These vouches and praises will become visible to ${contact.name.split(' ')[0]} when they join NAO.`
}
</Typography>
</Box>
</Box>
</Box>
)}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider', textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary">
1 vouch 2 praises sent
{contact.naoStatus === 'member' ? '1 vouch • 2 praises sent' : '1 vouch • 1 praise sent (hidden until they join)'}
</Typography>
</Box>
</CardContent>
@ -536,52 +739,75 @@ const ContactViewPage = () => {
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Vouch items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>TypeScript Skills</Typography>
<Typography variant="caption" color="text.secondary"> 2 days ago</Typography>
{contact.naoStatus === 'member' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Vouch items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>TypeScript Skills</Typography>
<Typography variant="caption" color="text.secondary"> 2 days ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Excellent TypeScript developer with attention to detail."
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Excellent TypeScript developer with attention to detail."
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Project Management</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 2 }}>
<VerifiedUser sx={{ color: 'primary.main', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Project Management</Typography>
<Typography variant="caption" color="text.secondary"> 1 week ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Great at coordinating teams and delivering on time."
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Great at coordinating teams and delivering on time."
</Typography>
</Box>
</Box>
{/* Praise items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Teamwork</Typography>
<Typography variant="caption" color="text.secondary"> 3 days ago</Typography>
{/* Praise items */}
<Box sx={{ display: 'flex', gap: 2, p: 2, bgcolor: alpha('#f8bbd9', 0.15), borderRadius: 2 }}>
<Favorite sx={{ color: '#d81b60', fontSize: 20, mt: 0.5, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Teamwork</Typography>
<Typography variant="caption" color="text.secondary"> 3 days ago</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Amazing collaborative spirit and helpful attitude."
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
"Amazing collaborative spirit and helpful attitude."
</Typography>
</Box>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 200, gap: 2 }}>
<PersonOutline sx={{ fontSize: 48, opacity: 0.3, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ maxWidth: 250 }}>
{contact.naoStatus === 'invited'
? `${contact.name.split(' ')[0]} hasn't joined NAO yet, so they can't send vouches or praises.`
: `${contact.name.split(' ')[0]} needs to join NAO before they can send vouches or praises.`
}
</Typography>
{contact.naoStatus === 'not_invited' && (
<Button
variant="outlined"
startIcon={<Send />}
onClick={handleInviteToNao}
size="small"
sx={{ mt: 1 }}
>
Invite to NAO
</Button>
)}
</Box>
)}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider', textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary">
2 vouches 1 praise received
{contact.naoStatus === 'member' ? '2 vouches • 1 praise received' : 'No vouches or praises yet'}
</Typography>
</Box>
</CardContent>

@ -1,27 +1,41 @@
import { Box, Typography, Container } from '@mui/material';
import PostCreateButton from '../components/PostCreateButton';
import { Box, Typography } from '@mui/material';
const FeedPage = () => {
const handleCreatePost = (type: 'post' | 'offer' | 'want') => {
console.log(`Creating ${type} in main feed`);
// TODO: Implement post creation logic
};
return (
<>
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h4" sx={{ mb: 3, fontWeight: 600 }}>
Feed
</Typography>
<Typography variant="body1" color="text.secondary">
Your personalized network feed will appear here.
</Typography>
</Box>
</Container>
<PostCreateButton onCreatePost={handleCreatePost} />
</>
<Box sx={{
height: '100%',
width: '100%',
maxWidth: { xs: '100vw', md: '100%' },
overflow: 'hidden',
boxSizing: 'border-box',
p: { xs: '10px', md: 0 },
mx: { xs: 0, md: 'auto' }
}}>
<Box sx={{
mb: { xs: 2, md: 3 },
width: '100%',
overflow: 'hidden',
minWidth: 0
}}>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: 700,
mb: 1,
fontSize: { xs: '1.5rem', md: '2.125rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
Home
</Typography>
<Typography variant="body1" color="text.secondary">
Your personalized dashboard, where you can place your own selection of widgets...
</Typography>
</Box>
</Box>
);
};

File diff suppressed because it is too large Load Diff

@ -85,27 +85,66 @@ const GroupPage = () => {
};
return (
<Box sx={{ height: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
<Box sx={{
height: '100%',
width: '100%',
maxWidth: { xs: '100vw', md: '100%' },
overflow: 'hidden',
boxSizing: 'border-box',
p: { xs: '10px', md: 0 },
mx: { xs: 0, md: 'auto' }
}}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: { xs: 2, md: 3 },
width: '100%',
overflow: 'hidden',
minWidth: 0
}}>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: 700,
mb: 1,
fontSize: { xs: '1.5rem', md: '2.125rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
Groups
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateGroup}
sx={{ borderRadius: 2 }}
sx={{
borderRadius: 2,
fontSize: { xs: '0.75rem', md: '0.875rem' },
px: { xs: 1, md: 2 }
}}
>
Create Group
</Button>
</Box>
</Box>
<Card sx={{ mb: 3 }}>
<Box sx={{ p: 3 }}>
<Card sx={{
mb: { xs: 0, md: 3 },
width: { xs: 'calc(100% + 20px)', md: '100%' },
maxWidth: { xs: 'calc(100vw - 0px)', md: '100%' },
overflow: 'hidden',
mx: { xs: '-10px', md: 0 }, // Extend to edges on mobile
borderRadius: { xs: 0, md: 1 }, // Remove border radius on mobile
boxSizing: 'border-box'
}}>
<Box sx={{ p: { xs: '10px', md: 3 } }}>
<TextField
fullWidth
placeholder="Search groups..."

@ -0,0 +1,415 @@
import { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Button,
Chip,
Avatar,
IconButton,
Divider,
Badge,
alpha,
useTheme
} from '@mui/material';
import {
Notifications,
VerifiedUser,
Favorite,
Group,
Message,
Settings,
CheckCircle,
Schedule,
Close,
MarkEmailRead
} from '@mui/icons-material';
import { notificationService } from '../services/notificationService';
import type { Notification, NotificationSummary } from '../types/notification';
const NotificationsPage = () => {
const theme = useTheme();
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 [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadNotifications = async () => {
setIsLoading(true);
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);
} finally {
setIsLoading(false);
}
};
loadNotifications();
}, []);
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 getNotificationIcon = (type: string) => {
switch (type) {
case 'vouch':
return <VerifiedUser sx={{ fontSize: 20, color: 'primary.main' }} />;
case 'praise':
return <Favorite sx={{ fontSize: 20, color: '#d81b60' }} />;
case 'group_invite':
return <Group sx={{ fontSize: 20, color: 'success.main' }} />;
case 'message':
return <Message sx={{ fontSize: 20, color: 'info.main' }} />;
case 'system':
return <Settings sx={{ fontSize: 20, color: 'warning.main' }} />;
default:
return <Notifications sx={{ fontSize: 20 }} />;
}
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
return (
<Box sx={{
height: '100%',
width: '100%',
maxWidth: { xs: '100vw', md: '100%' },
overflow: 'hidden',
boxSizing: 'border-box',
p: { xs: '10px', md: 0 },
mx: { xs: 0, md: 'auto' }
}}>
{/* Header */}
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: { xs: 2, md: 3 },
width: '100%',
overflow: 'hidden',
minWidth: 0
}}>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: 700,
mb: 1,
fontSize: { xs: '1.5rem', md: '2.125rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
Notifications
</Typography>
{notificationSummary.unread > 0 && (
<Typography variant="body2" color="text.secondary">
You have {notificationSummary.unread} unread notification{notificationSummary.unread !== 1 ? 's' : ''}
</Typography>
)}
</Box>
{notificationSummary.unread > 0 && (
<Button
variant="outlined"
startIcon={<MarkEmailRead />}
onClick={handleMarkAllAsRead}
sx={{
borderRadius: 2,
fontSize: { xs: '0.75rem', md: '0.875rem' },
px: { xs: 1, md: 2 },
py: { xs: 0.5, md: 1 }
}}
>
Mark All Read
</Button>
)}
</Box>
{/* Notifications List */}
<Card sx={{
width: { xs: 'calc(100% + 20px)', md: '100%' },
maxWidth: { xs: 'calc(100vw - 0px)', md: '100%' },
overflow: 'hidden',
mx: { xs: '-10px', md: 0 },
borderRadius: { xs: 0, md: 1 },
boxSizing: 'border-box'
}}>
<Box sx={{ p: { xs: '10px', md: 3 } }}>
{isLoading ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Loading notifications...
</Typography>
</Box>
) : notifications.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
No notifications yet
</Typography>
<Typography variant="body2" color="text.secondary">
You'll see notifications here when you receive vouches, praises, and other updates.
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{notifications.map((notification, index) => (
<Box key={notification.id}>
<Box sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 2,
py: 2,
backgroundColor: notification.isRead ? 'transparent' : alpha(theme.palette.primary.main, 0.02),
borderRadius: 1,
position: 'relative'
}}>
{/* Notification Icon */}
<Box sx={{ flexShrink: 0, mt: 0.5 }}>
{getNotificationIcon(notification.type)}
</Box>
{/* Main Content */}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{/* Sender Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Avatar
src={notification.sender?.avatar}
alt={notification.sender?.name}
sx={{ width: 24, height: 24, fontSize: '0.75rem' }}
>
{notification.sender?.name?.charAt(0)}
</Avatar>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{notification.sender?.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(notification.createdAt)}
</Typography>
{!notification.isRead && (
<Box sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: 'primary.main',
ml: 'auto'
}} />
)}
</Box>
{/* Message */}
<Typography variant="body2" sx={{ mb: 1, lineHeight: 1.5 }}>
{notification.message}
</Typography>
{/* Status and Actions */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{notification.status && (
<Chip
icon={notification.status === 'accepted' ? <CheckCircle /> : <Schedule />}
label={notification.status}
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
height: 20,
textTransform: 'capitalize',
...(notification.status === 'accepted' && {
backgroundColor: alpha(theme.palette.success.main, 0.08),
borderColor: alpha(theme.palette.success.main, 0.2),
color: 'success.main'
})
}}
/>
)}
{/* Action Buttons */}
{notification.isActionable && notification.status === 'pending' && (
<Box sx={{ display: 'flex', gap: 1, ml: 'auto' }}>
{notification.type === 'vouch' && (
<>
<Button
size="small"
variant="outlined"
onClick={() => handleRejectVouch(notification.id, notification.metadata?.vouchId || '')}
sx={{
minWidth: 60,
fontSize: '0.75rem',
py: 0.25
}}
>
Reject
</Button>
<Button
size="small"
variant="contained"
onClick={() => handleAcceptVouch(notification.id, notification.metadata?.vouchId || '')}
sx={{
minWidth: 60,
fontSize: '0.75rem',
py: 0.25
}}
>
Accept
</Button>
</>
)}
{notification.type === 'praise' && (
<>
<Button
size="small"
variant="outlined"
onClick={() => handleRejectPraise(notification.id, notification.metadata?.praiseId || '')}
sx={{
minWidth: 60,
fontSize: '0.75rem',
py: 0.25
}}
>
Reject
</Button>
<Button
size="small"
variant="contained"
onClick={() => handleAcceptPraise(notification.id, notification.metadata?.praiseId || '')}
sx={{
minWidth: 60,
fontSize: '0.75rem',
py: 0.25
}}
>
Accept
</Button>
</>
)}
</Box>
)}
{/* Mark as Read Button */}
{!notification.isRead && (
<IconButton
size="small"
onClick={() => handleMarkAsRead(notification.id)}
sx={{ ml: 'auto' }}
>
<Close sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
</Box>
</Box>
{index < notifications.length - 1 && <Divider />}
</Box>
))}
</Box>
)}
</Box>
</Card>
</Box>
);
};
export default NotificationsPage;

@ -5,4 +5,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
assetsInclude: ['**/*.json'],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
open: false
}
})

Loading…
Cancel
Save