Implement existing member group invitation flow with rCard selection

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
Claude Code Assistant 2 months ago
parent 3b5653ad36
commit c28d62fb78
  1. 3
      src/App.tsx
  2. 13
      src/pages/GroupDetailPage.tsx
  3. 288
      src/pages/GroupJoinPage.tsx
  4. 49
      src/pages/InvitationPage.tsx
  5. 8
      src/pages/SocialContractPage.tsx

@ -4,6 +4,7 @@ import CssBaseline from '@mui/material/CssBaseline';
import { OnboardingProvider } from './context/OnboardingContext'; import { OnboardingProvider } from './context/OnboardingContext';
import DashboardLayout from './components/layout/DashboardLayout'; import DashboardLayout from './components/layout/DashboardLayout';
import SocialContractPage from './pages/SocialContractPage'; import SocialContractPage from './pages/SocialContractPage';
import GroupJoinPage from './pages/GroupJoinPage';
import ImportPage from './pages/ImportPage'; import ImportPage from './pages/ImportPage';
import ContactListPage from './pages/ContactListPage'; import ContactListPage from './pages/ContactListPage';
import ContactViewPage from './pages/ContactViewPage'; import ContactViewPage from './pages/ContactViewPage';
@ -28,6 +29,8 @@ function App() {
<Routes> <Routes>
{/* Social Contract page - outside DashboardLayout */} {/* Social Contract page - outside DashboardLayout */}
<Route path="/onboarding" element={<SocialContractPage />} /> <Route path="/onboarding" element={<SocialContractPage />} />
{/* Group Join page for existing members - outside DashboardLayout */}
<Route path="/join-group" element={<GroupJoinPage />} />
{/* Main app routes - inside DashboardLayout */} {/* Main app routes - inside DashboardLayout */}
<Route path="/*" element={ <Route path="/*" element={

@ -104,6 +104,17 @@ const GroupDetailPage = () => {
if ((fromInvite || newMember) && groupData) { if ((fromInvite || newMember) && groupData) {
// Mark as visited and open AI assistant directly // Mark as visited and open AI assistant directly
localStorage.setItem(hasVisitedKey, 'true'); localStorage.setItem(hasVisitedKey, 'true');
// Check if this is an existing member who just selected their rCard
const existingMember = searchParams.get('existingMember') === 'true';
const selectedRCard = searchParams.get('rCard');
if (existingMember && selectedRCard) {
// Store the selected rCard for this group membership
sessionStorage.setItem(`groupRCard_${groupId}`, selectedRCard);
console.log(`User joined ${groupData.name} with rCard: ${selectedRCard}`);
}
setTimeout(() => setShowAIAssistant(true), 1000); // Small delay for better UX setTimeout(() => setShowAIAssistant(true), 1000); // Small delay for better UX
// Clean up URL parameters after showing the welcome // Clean up URL parameters after showing the welcome
@ -113,6 +124,8 @@ const GroupDetailPage = () => {
newSearchParams.delete('newMember'); newSearchParams.delete('newMember');
newSearchParams.delete('firstName'); newSearchParams.delete('firstName');
newSearchParams.delete('inviteeName'); newSearchParams.delete('inviteeName');
newSearchParams.delete('existingMember');
newSearchParams.delete('rCard');
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
} }
} }

@ -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;

@ -14,7 +14,9 @@ import {
TextField, TextField,
InputAdornment, InputAdornment,
Avatar, Avatar,
Chip Chip,
FormControlLabel,
Switch
} from '@mui/material'; } from '@mui/material';
import { import {
Share, Share,
@ -38,6 +40,7 @@ const InvitationPage = () => {
inviterName?: string; inviterName?: string;
relationshipType?: string; relationshipType?: string;
}>({}); }>({});
const [isExistingMember, setIsExistingMember] = useState(false);
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
const [invitationId, setInvitationId] = useState(''); const [invitationId, setInvitationId] = useState('');
const [group, setGroup] = useState<Group | null>(null); const [group, setGroup] = useState<Group | null>(null);
@ -80,6 +83,7 @@ const InvitationPage = () => {
...(inviteeName && { inviteeName }), ...(inviteeName && { inviteeName }),
...(inviterName && { inviterName }), ...(inviterName && { inviterName }),
...(relationshipType && { relationshipType }), ...(relationshipType && { relationshipType }),
...(isExistingMember && { existingMember: 'true' }),
}); });
const url = `${window.location.origin}/onboarding?${urlParams.toString()}`; const url = `${window.location.origin}/onboarding?${urlParams.toString()}`;
@ -196,6 +200,7 @@ const InvitationPage = () => {
...(inviteeName && { inviteeName }), ...(inviteeName && { inviteeName }),
...(inviterName && { inviterName }), ...(inviterName && { inviterName }),
...(relationshipType && { relationshipType }), ...(relationshipType && { relationshipType }),
...(isExistingMember && { existingMember: 'true' }),
}); });
const url = `${window.location.origin}/onboarding?${urlParams.toString()}`; const url = `${window.location.origin}/onboarding?${urlParams.toString()}`;
@ -342,6 +347,48 @@ const InvitationPage = () => {
Invitation ID: {invitationId} Invitation ID: {invitationId}
</Typography> </Typography>
{/* Existing Member Toggle */}
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={isExistingMember}
onChange={(e) => {
setIsExistingMember(e.target.checked);
// Regenerate URL when toggle changes
const id = Math.random().toString(36).substring(2, 15);
setInvitationId(id);
const groupId = searchParams.get('groupId');
const inviteeName = searchParams.get('inviteeName');
const inviterName = searchParams.get('inviterName');
const relationshipType = searchParams.get('relationshipType');
const urlParams = new URLSearchParams({
invite: id,
...(groupId && { groupId }),
...(inviteeName && { inviteeName }),
...(inviterName && { inviterName }),
...(relationshipType && { relationshipType }),
...(e.target.checked && { existingMember: 'true' }),
});
const url = `${window.location.origin}/onboarding?${urlParams.toString()}`;
setInvitationUrl(url);
}}
color="primary"
/>
}
label="Recipient is already a NAO member"
sx={{ alignItems: 'flex-start' }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{isExistingMember
? "They'll choose how to connect with this group using their existing profile"
: "They'll need to create a new NAO account first"
}
</Typography>
</Box>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>

@ -48,6 +48,14 @@ const SocialContractPage = () => {
const loadGroupData = async () => { const loadGroupData = async () => {
const groupId = searchParams.get('groupId'); const groupId = searchParams.get('groupId');
const inviteId = searchParams.get('invite'); const inviteId = searchParams.get('invite');
const existingMember = searchParams.get('existingMember') === 'true';
// If this is for an existing member and it's a group invite, redirect to group join page
if (existingMember && groupId) {
const joinParams = new URLSearchParams(searchParams);
navigate(`/join-group?${joinParams.toString()}`);
return;
}
// Extract invite personalization data // Extract invite personalization data
const inviteeName = searchParams.get('inviteeName'); const inviteeName = searchParams.get('inviteeName');

Loading…
Cancel
Save