FEATURES: - New GroupJoinPage for existing NAO members to select relationship type (rCard) - Toggle on invitation page to specify if recipient is existing member - Automatic routing: existing members → rCard selection, new users → full onboarding - Privacy-focused UI explaining information sharing based on selected rCard COMPONENTS: - GroupJoinPage: Beautiful rCard selection interface with privacy explanations - Updated InvitationPage: Added "Recipient is already a NAO member" toggle - Updated SocialContractPage: Routes existing members to GroupJoinPage - Updated GroupDetailPage: Handles rCard selection data and URL cleanup FLOW: New Users: Invitation → Social Contract → Onboarding → Group Existing Users: Invitation → rCard Selection → Group (with chosen privacy level) rCard determines information sharing level (Business, Colleague, Friend, etc.) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>main
parent
3b5653ad36
commit
c28d62fb78
@ -0,0 +1,288 @@ |
||||
import { useState, useEffect } from 'react'; |
||||
import { useNavigate, useSearchParams } from 'react-router-dom'; |
||||
import { |
||||
Container, |
||||
Typography, |
||||
Box, |
||||
Paper, |
||||
Button, |
||||
Avatar, |
||||
Card, |
||||
CardContent, |
||||
Chip, |
||||
alpha, |
||||
useTheme |
||||
} from '@mui/material'; |
||||
import { |
||||
Business, |
||||
PersonOutline, |
||||
Groups, |
||||
FamilyRestroom, |
||||
Favorite, |
||||
Home, |
||||
ArrowBack, |
||||
CheckCircle |
||||
} from '@mui/icons-material'; |
||||
import { dataService } from '../services/dataService'; |
||||
import { DEFAULT_RCARDS } from '../types/notification'; |
||||
import type { Group } from '../types/group'; |
||||
|
||||
const GroupJoinPage = () => { |
||||
const [group, setGroup] = useState<Group | null>(null); |
||||
const [selectedRCard, setSelectedRCard] = useState<string>(''); |
||||
const [inviterName, setInviterName] = useState<string>(''); |
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const navigate = useNavigate(); |
||||
const [searchParams] = useSearchParams(); |
||||
const theme = useTheme(); |
||||
|
||||
useEffect(() => { |
||||
const loadGroupData = async () => { |
||||
const groupId = searchParams.get('groupId'); |
||||
const inviter = searchParams.get('inviterName') || 'Someone'; |
||||
|
||||
setInviterName(inviter); |
||||
|
||||
if (groupId) { |
||||
try { |
||||
const groupData = await dataService.getGroup(groupId); |
||||
setGroup(groupData || null); |
||||
} catch (error) { |
||||
console.error('Failed to load group:', error); |
||||
} |
||||
} |
||||
|
||||
setIsLoading(false); |
||||
}; |
||||
|
||||
loadGroupData(); |
||||
}, [searchParams]); |
||||
|
||||
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 handleRCardSelect = (rCardName: string) => { |
||||
setSelectedRCard(rCardName); |
||||
}; |
||||
|
||||
const handleJoinGroup = () => { |
||||
if (!selectedRCard || !group) return; |
||||
|
||||
// Store the selected rCard for this group membership
|
||||
sessionStorage.setItem(`groupRCard_${group.id}`, selectedRCard); |
||||
|
||||
// Navigate to group with member parameters
|
||||
const params = new URLSearchParams({ |
||||
newMember: 'true', |
||||
fromInvite: 'true', |
||||
existingMember: 'true', |
||||
rCard: selectedRCard |
||||
}); |
||||
navigate(`/groups/${group.id}?${params.toString()}`); |
||||
}; |
||||
|
||||
const handleBack = () => { |
||||
navigate(-1); |
||||
}; |
||||
|
||||
if (isLoading) { |
||||
return ( |
||||
<Container maxWidth="md" sx={{ py: 4, display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}> |
||||
<Typography variant="h6" color="text.secondary"> |
||||
Loading... |
||||
</Typography> |
||||
</Container> |
||||
); |
||||
} |
||||
|
||||
if (!group) { |
||||
return ( |
||||
<Container maxWidth="md" sx={{ py: 4, display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}> |
||||
<Typography variant="h6" color="text.secondary"> |
||||
Group not found |
||||
</Typography> |
||||
</Container> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Container maxWidth="md" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> |
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center', |
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`, |
||||
border: 1, |
||||
borderColor: 'divider' |
||||
}} |
||||
> |
||||
{/* Back Button */} |
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, justifyContent: 'flex-start' }}> |
||||
<Button |
||||
onClick={handleBack} |
||||
startIcon={<ArrowBack />} |
||||
variant="text" |
||||
sx={{ color: 'text.secondary' }} |
||||
> |
||||
Back |
||||
</Button> |
||||
</Box> |
||||
|
||||
{/* Header */} |
||||
<Box sx={{ mb: 4 }}> |
||||
<Typography variant="h3" component="h1" gutterBottom> |
||||
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 |
||||
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 */} |
||||
<Box sx={{ mb: 4 }}> |
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}> |
||||
<CheckCircle color="primary" /> |
||||
Select Your Relationship Type |
||||
</Typography> |
||||
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
mt: 3, |
||||
maxWidth: '800px', |
||||
mx: 'auto' |
||||
}}> |
||||
{DEFAULT_RCARDS.map((rCard) => ( |
||||
<Card |
||||
key={rCard.name} |
||||
onClick={() => handleRCardSelect(rCard.name)} |
||||
sx={{ |
||||
cursor: 'pointer', |
||||
transition: 'all 0.2s ease-in-out', |
||||
border: 2, |
||||
borderColor: selectedRCard === rCard.name ? rCard.color : 'divider', |
||||
backgroundColor: selectedRCard === rCard.name
|
||||
? alpha(rCard.color || theme.palette.primary.main, 0.08) |
||||
: 'background.paper', |
||||
'&:hover': { |
||||
borderColor: rCard.color, |
||||
transform: 'translateY(-2px)', |
||||
boxShadow: theme.shadows[4], |
||||
}, |
||||
}} |
||||
> |
||||
<CardContent sx={{ p: 3, textAlign: 'center' }}> |
||||
<Avatar |
||||
sx={{ |
||||
bgcolor: rCard.color || theme.palette.primary.main, |
||||
width: 48, |
||||
height: 48, |
||||
mx: 'auto', |
||||
mb: 2, |
||||
'& .MuiSvgIcon-root': { fontSize: 28 } |
||||
}} |
||||
> |
||||
{getRCardIcon(rCard.icon || 'PersonOutline')} |
||||
</Avatar> |
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}> |
||||
{rCard.name} |
||||
</Typography> |
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.875rem' }}> |
||||
{rCard.description} |
||||
</Typography> |
||||
|
||||
{selectedRCard === rCard.name && ( |
||||
<CheckCircle
|
||||
sx={{
|
||||
color: rCard.color,
|
||||
mt: 1, |
||||
fontSize: 20
|
||||
}}
|
||||
/> |
||||
)} |
||||
</CardContent> |
||||
</Card> |
||||
))} |
||||
</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 */} |
||||
<Button |
||||
variant="contained" |
||||
size="large" |
||||
onClick={handleJoinGroup} |
||||
disabled={!selectedRCard} |
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5, |
||||
borderRadius: 2, |
||||
textTransform: 'none', |
||||
fontSize: '1.1rem', |
||||
minWidth: 200 |
||||
}} |
||||
> |
||||
Join {group.name} |
||||
</Button> |
||||
</Paper> |
||||
</Container> |
||||
); |
||||
}; |
||||
|
||||
export default GroupJoinPage; |
Loading…
Reference in new issue