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. 59
      src/pages/GroupInfoPage.tsx
  6. 76
      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,40 +309,22 @@ 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" component="h1"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: '1.5rem', md: '2.125rem' },
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{group.name}
</Typography>
{getPrivacyIcon(group.isPrivate)}
</Box>
<Button
variant="contained"
startIcon={<PersonAdd />}
onClick={handleInviteToGroup}
sx={{ sx={{
borderRadius: 2, fontWeight: 700,
px: { xs: 1.5, md: 3 }, fontSize: { xs: '1.5rem', md: '2.125rem' },
py: { xs: 0.5, md: 1 }, lineHeight: 1.2,
fontSize: { xs: '0.75rem', md: '0.875rem' }, overflow: 'hidden',
flexShrink: 0, textOverflow: 'ellipsis',
minWidth: { xs: 'auto', md: 'auto' }, whiteSpace: 'nowrap'
mr: { xs: 0.5, md: 0 } // Add right margin on mobile
}} }}
> >
Invite {group.name}
</Button> </Typography>
{getPrivacyIcon(group.isPrivate)}
</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,46 +151,39 @@ 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} <Avatar
</Typography> src={group.image}
alt={group.name}
sx={{
width: 64,
height: 64,
bgcolor: 'white',
border: 1,
borderColor: 'primary.main',
color: 'primary.main'
}}
>
{group.name.charAt(0)}
</Avatar>
<Typography variant="h3" component="h1">
Join {group.name}
</Typography>
{group.isPrivate && (
<Chip label="Private Group" size="small" variant="outlined" />
)}
</Box>
<Typography variant="h6" color="primary" gutterBottom> <Typography variant="h6" color="primary" gutterBottom>
Choose your relationship type Choose your relationship type
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: '600px', mx: 'auto' }}> <Typography variant="body1" color="text.secondary" sx={{ maxWidth: '600px', mx: 'auto' }}>
{inviterName} has invited you to join <strong>{group.name}</strong>. {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. Select how you'd like to connect with this group. This determines what personal information will be visible to group members.
</Typography> </Typography>
</Box> </Box>
{/* Group Info */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mb: 4 }}>
<Avatar
src={group.image}
alt={group.name}
sx={{
width: 64,
height: 64,
bgcolor: 'white',
border: 1,
borderColor: 'primary.main',
color: 'primary.main'
}}
>
{group.name.charAt(0)}
</Avatar>
<Box>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{group.name}
</Typography>
{group.isPrivate && (
<Chip label="Private Group" size="small" variant="outlined" sx={{ mt: 1 }} />
)}
</Box>
</Box>
{/* rCard Selection */} {/* rCard Selection */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}> <Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
@ -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