Add 'Local community' and 'Online community' rCards with balanced layouts

- Add two new default rCards: 'Local community' (LocationOn icon, green) and 'Online community' (Public icon, purple)
- Update all icon mappings across InviteForm, GroupJoinPage, AccountPage, NotificationItem, and DashboardLayout
- Add new relationship categories to DashboardLayout sidebar drag-and-drop areas
- Update GroupJoinPage layout to 2 rows of 4 (repeat(4, 1fr)) for balanced grid display
- Clean up unused imports and variables from invite form refactoring
- All components now support the full set of 8 relationship types

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

Co-Authored-By: Claude <noreply@anthropic.com>
main
Claude Code Assistant 2 months ago
parent 115cba62a0
commit 986c4b00ca
  1. 97
      src/components/invite/InviteForm.tsx
  2. 4
      src/components/layout/DashboardLayout.tsx
  3. 6
      src/components/notifications/NotificationItem.tsx
  4. 6
      src/pages/AccountPage.tsx
  5. 33
      src/pages/GroupInfoPage.tsx
  6. 56
      src/pages/GroupJoinPage.tsx
  7. 46
      src/pages/InvitationPage.tsx
  8. 14
      src/types/notification.ts

@ -8,17 +8,10 @@ import {
TextField, TextField,
Box, Box,
Typography, Typography,
Avatar,
useTheme, useTheme,
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
Business,
PersonOutline,
Groups,
FamilyRestroom,
Favorite,
Home,
PersonAdd, PersonAdd,
ContactPage, ContactPage,
} from '@mui/icons-material'; } from '@mui/icons-material';
@ -72,8 +65,6 @@ const InviteForm: React.FC<InviteFormProps> = ({
relationshipType: '', relationshipType: '',
inviterName: 'Oli S-B', // Current user inviterName: 'Oli S-B', // Current user
}); });
const [selectedRelationship, setSelectedRelationship] = useState<string>('');
// Handle prefilled contact data // Handle prefilled contact data
useEffect(() => { useEffect(() => {
if (prefilledContact) { if (prefilledContact) {
@ -85,35 +76,23 @@ const InviteForm: React.FC<InviteFormProps> = ({
} }
}, [prefilledContact]); }, [prefilledContact]);
const getRCardIcon = (iconName: string) => {
const iconMap: Record<string, React.ReactElement> = {
Business: <Business />,
PersonOutline: <PersonOutline />,
Groups: <Groups />,
FamilyRestroom: <FamilyRestroom />,
Favorite: <Favorite />,
Home: <Home />,
};
return iconMap[iconName] || <PersonOutline />;
};
const handleSubmit = () => { const handleSubmit = () => {
if (!formData.inviteeName || !formData.inviteeEmail || !selectedRelationship) { if (!formData.inviteeName || !formData.inviteeEmail) {
return; // TODO: Add validation feedback return; // TODO: Add validation feedback
} }
const selectedRCard = DEFAULT_RCARDS.find(card => card.name === selectedRelationship); const defaultRCard = DEFAULT_RCARDS[0]; // Use first relationship as default
if (selectedRCard && formData.inviteeName && formData.inviteeEmail) { if (formData.inviteeName && formData.inviteeEmail) {
const inviteData: InviteFormData = { const inviteData: InviteFormData = {
inviteeName: formData.inviteeName, inviteeName: formData.inviteeName,
inviteeEmail: formData.inviteeEmail, inviteeEmail: formData.inviteeEmail,
relationshipType: selectedRelationship, relationshipType: defaultRCard.name,
relationshipData: { relationshipData: {
name: selectedRCard.name || 'Unknown', name: defaultRCard.name || 'Unknown',
description: selectedRCard.description || 'No description', description: defaultRCard.description || 'No description',
color: selectedRCard.color || '#2563eb', color: defaultRCard.color || '#2563eb',
icon: selectedRCard.icon || 'PersonOutline', icon: defaultRCard.icon || 'PersonOutline',
}, },
inviterName: formData.inviterName || 'Current User', inviterName: formData.inviterName || 'Current User',
}; };
@ -122,14 +101,6 @@ const InviteForm: React.FC<InviteFormProps> = ({
} }
}; };
const handleRelationshipSelect = (relationshipName: string) => {
setSelectedRelationship(relationshipName);
setFormData(prev => ({
...prev,
relationshipType: relationshipName
}));
};
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle> <DialogTitle>
@ -204,56 +175,6 @@ const InviteForm: React.FC<InviteFormProps> = ({
</Box> </Box>
</Box> </Box>
{/* Relationship Selection */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
What's your relationship with them?
</Typography>
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
'@media (max-width: 600px)': {
flexDirection: 'column'
}
}}>
{DEFAULT_RCARDS.map((rCard) => (
<Button
key={rCard.name}
variant={selectedRelationship === rCard.name ? "contained" : "outlined"}
onClick={() => handleRelationshipSelect(rCard.name)}
startIcon={
<Avatar
sx={{
bgcolor: selectedRelationship === rCard.name ? 'white' : rCard.color,
color: selectedRelationship === rCard.name ? rCard.color : 'white',
width: 24,
height: 24,
'& .MuiSvgIcon-root': { fontSize: 16 }
}}
>
{getRCardIcon(rCard.icon || 'PersonOutline')}
</Avatar>
}
sx={{
flex: { xs: 'none', sm: '1 1 auto' },
minWidth: 'fit-content',
borderRadius: 2,
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
transform: 'translateY(-1px)',
boxShadow: theme.shadows[4],
},
}}
>
{rCard.name}
</Button>
))}
</Box>
</Box>
</DialogContent> </DialogContent>
@ -264,7 +185,7 @@ const InviteForm: React.FC<InviteFormProps> = ({
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
variant="contained" variant="contained"
disabled={!formData.inviteeName || !formData.inviteeEmail || !selectedRelationship} disabled={!formData.inviteeName || !formData.inviteeEmail}
> >
Create Invite Create Invite
</Button> </Button>

@ -34,6 +34,8 @@ import {
Work, Work,
People, People,
AutoAwesome, AutoAwesome,
LocationOn,
Public,
} from '@mui/icons-material'; } from '@mui/icons-material';
import BottomNavigation from '../navigation/BottomNavigation'; import BottomNavigation from '../navigation/BottomNavigation';
import { notificationService } from '../../services/notificationService'; import { notificationService } from '../../services/notificationService';
@ -90,6 +92,8 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => {
{ id: 'colleague', name: 'Colleague', icon: <Work />, color: '#1976d2', count: 0 }, { id: 'colleague', name: 'Colleague', icon: <Work />, color: '#1976d2', count: 0 },
{ id: 'business', name: 'Business', icon: <Business />, color: '#7b1fa2', count: 0 }, { id: 'business', name: 'Business', icon: <Business />, color: '#7b1fa2', count: 0 },
{ id: 'acquaintance', name: 'Acquaintance', icon: <Groups />, color: '#616161', count: 0 }, { id: 'acquaintance', name: 'Acquaintance', icon: <Groups />, color: '#616161', count: 0 },
{ id: 'local_community', name: 'Local community', icon: <LocationOn />, color: '#059669', count: 0 },
{ id: 'online_community', name: 'Online community', icon: <Public />, color: '#7c3aed', count: 0 },
]; ];
// Load notification summary for badge // Load notification summary for badge

@ -32,6 +32,8 @@ import {
FamilyRestroom, FamilyRestroom,
Favorite, Favorite,
Home, Home,
LocationOn,
Public,
} from '@mui/icons-material'; } from '@mui/icons-material';
import type { Notification } from '../../types/notification'; import type { Notification } from '../../types/notification';
import { DEFAULT_RCARDS } from '../../types/notification'; import { DEFAULT_RCARDS } from '../../types/notification';
@ -147,6 +149,10 @@ const NotificationItem = ({
return <Favorite />; return <Favorite />;
case 'Home': case 'Home':
return <Home />; return <Home />;
case 'LocationOn':
return <LocationOn />;
case 'Public':
return <Public />;
default: default:
return <PersonOutline />; return <PersonOutline />;
} }

@ -29,6 +29,8 @@ import {
Timeline, Timeline,
Settings, Settings,
Logout, Logout,
LocationOn,
Public,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { DEFAULT_RCARDS, DEFAULT_PRIVACY_SETTINGS } from '../types/notification'; import { DEFAULT_RCARDS, DEFAULT_PRIVACY_SETTINGS } from '../types/notification';
import type { RCardWithPrivacy } from '../types/notification'; import type { RCardWithPrivacy } from '../types/notification';
@ -236,6 +238,10 @@ const AccountPage = () => {
return <Favorite />; return <Favorite />;
case 'Home': case 'Home':
return <Home />; return <Home />;
case 'LocationOn':
return <LocationOn />;
case 'Public':
return <Public />;
default: default:
return <PersonOutline />; return <PersonOutline />;
} }

@ -309,7 +309,6 @@ const GroupInfoPage = () => {
{group.name.charAt(0)} {group.name.charAt(0)}
</Avatar> </Avatar>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}> <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography <Typography
variant="h4" variant="h4"
@ -327,23 +326,6 @@ const GroupInfoPage = () => {
</Typography> </Typography>
{getPrivacyIcon(group.isPrivate)} {getPrivacyIcon(group.isPrivate)}
</Box> </Box>
<Button
variant="contained"
startIcon={<PersonAdd />}
onClick={handleInviteToGroup}
sx={{
borderRadius: 2,
px: { xs: 1.5, md: 3 },
py: { xs: 0.5, md: 1 },
fontSize: { xs: '0.75rem', md: '0.875rem' },
flexShrink: 0,
minWidth: { xs: 'auto', md: 'auto' },
mr: { xs: 0.5, md: 0 } // Add right margin on mobile
}}
>
Invite
</Button>
</Box>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -389,6 +371,21 @@ const GroupInfoPage = () => {
<Typography variant="h6" sx={{ fontWeight: 600 }}> <Typography variant="h6" sx={{ fontWeight: 600 }}>
Members ({members.length}) Members ({members.length})
</Typography> </Typography>
<Button
variant="contained"
startIcon={<PersonAdd />}
onClick={handleInviteToGroup}
sx={{
borderRadius: 2,
px: { xs: 1.5, md: 3 },
py: { xs: 0.5, md: 1 },
fontSize: { xs: '0.75rem', md: '0.875rem' },
flexShrink: 0,
minWidth: { xs: 'auto', md: 'auto' }
}}
>
Invite
</Button>
</Box> </Box>
<List sx={{ width: '100%' }}> <List sx={{ width: '100%' }}>

@ -21,7 +21,9 @@ import {
Favorite, Favorite,
Home, Home,
ArrowBack, ArrowBack,
CheckCircle CheckCircle,
LocationOn,
Public
} from '@mui/icons-material'; } from '@mui/icons-material';
import { dataService } from '../services/dataService'; import { dataService } from '../services/dataService';
import { DEFAULT_RCARDS } from '../types/notification'; import { DEFAULT_RCARDS } from '../types/notification';
@ -73,6 +75,8 @@ const GroupJoinPage = () => {
FamilyRestroom: <FamilyRestroom />, FamilyRestroom: <FamilyRestroom />,
Favorite: <Favorite />, Favorite: <Favorite />,
Home: <Home />, Home: <Home />,
LocationOn: <LocationOn />,
Public: <Public />,
}; };
return iconMap[iconName] || <PersonOutline />; return iconMap[iconName] || <PersonOutline />;
}; };
@ -147,22 +151,7 @@ const GroupJoinPage = () => {
{/* Header */} {/* Header */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mb: 3 }}>
Join {group.name}
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
Choose your relationship type
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: '600px', mx: 'auto' }}>
{inviterName} has invited you to join <strong>{group.name}</strong>.
Select how you'd like to connect with this group. This determines what personal information will be visible to group members.
</Typography>
</Box>
{/* Group Info */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mb: 4 }}>
<Avatar <Avatar
src={group.image} src={group.image}
alt={group.name} alt={group.name}
@ -177,14 +166,22 @@ const GroupJoinPage = () => {
> >
{group.name.charAt(0)} {group.name.charAt(0)}
</Avatar> </Avatar>
<Box> <Typography variant="h3" component="h1">
<Typography variant="h5" sx={{ fontWeight: 600 }}> Join {group.name}
{group.name}
</Typography> </Typography>
{group.isPrivate && ( {group.isPrivate && (
<Chip label="Private Group" size="small" variant="outlined" sx={{ mt: 1 }} /> <Chip label="Private Group" size="small" variant="outlined" />
)} )}
</Box> </Box>
<Typography variant="h6" color="primary" gutterBottom>
Choose your relationship type
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: '600px', mx: 'auto' }}>
{inviterName} has invited you to join this group.
Select how you'd like to connect with this group. This determines what personal information will be visible to group members.
</Typography>
</Box> </Box>
{/* rCard Selection */} {/* rCard Selection */}
@ -196,10 +193,10 @@ const GroupJoinPage = () => {
<Box sx={{ <Box sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' },
gap: 2, gap: 2,
mt: 3, mt: 3,
maxWidth: '800px', maxWidth: '1000px',
mx: 'auto' mx: 'auto'
}}> }}>
{DEFAULT_RCARDS.map((rCard) => ( {DEFAULT_RCARDS.map((rCard) => (
@ -258,17 +255,6 @@ const GroupJoinPage = () => {
</Box> </Box>
</Box> </Box>
{/* Privacy Info */}
<Box sx={{ mb: 4, p: 3, backgroundColor: alpha(theme.palette.info.main, 0.04), borderRadius: 2, border: 1, borderColor: alpha(theme.palette.info.main, 0.12) }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
<strong>Privacy Note:</strong> Your selected relationship type determines:
</Typography>
<Typography component="ul" variant="body2" color="text.secondary" sx={{ textAlign: 'left', maxWidth: '500px', mx: 'auto', pl: 3 }}>
<li>What profile information is visible to group members</li>
<li>How you appear in group member directories</li>
<li>What data is shared for group activities and collaboration</li>
</Typography>
</Box>
{/* Action Button */} {/* Action Button */}
<Button <Button
@ -285,7 +271,7 @@ const GroupJoinPage = () => {
minWidth: 200 minWidth: 200
}} }}
> >
Join {group.name} Join Group
</Button> </Button>
</Paper> </Paper>
</Container> </Container>

@ -25,10 +25,7 @@ import {
GetApp, GetApp,
Refresh, Refresh,
ArrowBack, ArrowBack,
Groups, Groups
CheckCircle,
Schedule,
PersonOutline
} from '@mui/icons-material'; } from '@mui/icons-material';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { dataService } from '../services/dataService'; import { dataService } from '../services/dataService';
@ -46,7 +43,6 @@ const InvitationPage = () => {
const [invitationId, setInvitationId] = useState(''); const [invitationId, setInvitationId] = useState('');
const [group, setGroup] = useState<Group | null>(null); const [group, setGroup] = useState<Group | null>(null);
const [isGroupInvite, setIsGroupInvite] = useState(false); const [isGroupInvite, setIsGroupInvite] = useState(false);
const [inviteeNaoStatus, setInviteeNaoStatus] = useState<'member' | 'invited' | 'not_invited' | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -76,10 +72,7 @@ const InvitationPage = () => {
if (contact) { if (contact) {
isExistingMember = contact.naoStatus === 'member'; isExistingMember = contact.naoStatus === 'member';
setInviteeNaoStatus(contact.naoStatus || 'not_invited');
console.log(`${inviteeName} NAO status:`, contact.naoStatus); console.log(`${inviteeName} NAO status:`, contact.naoStatus);
} else {
setInviteeNaoStatus('not_invited');
} }
} catch (error) { } catch (error) {
console.error('Failed to check contacts:', error); console.error('Failed to check contacts:', error);
@ -227,9 +220,6 @@ const InvitationPage = () => {
if (contact) { if (contact) {
isExistingMember = contact.naoStatus === 'member'; isExistingMember = contact.naoStatus === 'member';
setInviteeNaoStatus(contact.naoStatus || 'not_invited');
} else {
setInviteeNaoStatus('not_invited');
} }
} catch (error) { } catch (error) {
console.error('Failed to check contacts:', error); console.error('Failed to check contacts:', error);
@ -390,40 +380,6 @@ const InvitationPage = () => {
Invitation ID: {invitationId} Invitation ID: {invitationId}
</Typography> </Typography>
{/* NAO Status Indicator */}
{personalizedInvite.inviteeName && inviteeNaoStatus && (
<Box sx={{ mb: 2, p: 1.5, borderRadius: 2, backgroundColor: 'grey.50', border: 1, borderColor: 'divider' }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
Recipient Status:
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{inviteeNaoStatus === 'member' && (
<>
<CheckCircle sx={{ fontSize: 16, color: 'success.main' }} />
<Typography variant="body2" color="success.main">
<strong>{personalizedInvite.inviteeName}</strong> is already a NAO member - they'll choose how to connect with this group
</Typography>
</>
)}
{inviteeNaoStatus === 'invited' && (
<>
<Schedule sx={{ fontSize: 16, color: 'warning.main' }} />
<Typography variant="body2" color="warning.main">
<strong>{personalizedInvite.inviteeName}</strong> has been invited to NAO but hasn't joined yet
</Typography>
</>
)}
{inviteeNaoStatus === 'not_invited' && (
<>
<PersonOutline sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary">
<strong>{personalizedInvite.inviteeName}</strong> will need to create a new NAO account
</Typography>
</>
)}
</Box>
</Box>
)}
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />

@ -193,4 +193,18 @@ export const DEFAULT_RCARDS: Omit<RCard, 'id' | 'createdAt' | 'updatedAt'>[] = [
icon: 'Home', icon: 'Home',
isDefault: true, isDefault: true,
}, },
{
name: 'Local community',
description: 'Neighbors and local community members',
color: '#059669',
icon: 'LocationOn',
isDefault: true,
},
{
name: 'Online community',
description: 'Online connections and digital communities',
color: '#7c3aed',
icon: 'Public',
isDefault: true,
},
]; ];
Loading…
Cancel
Save