From c28d62fb7800f42c832b564c06b28a5a0ababdeb Mon Sep 17 00:00:00 2001 From: Claude Code Assistant Date: Thu, 24 Jul 2025 10:46:15 +0100 Subject: [PATCH] Implement existing member group invitation flow with rCard selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 3 + src/pages/GroupDetailPage.tsx | 13 ++ src/pages/GroupJoinPage.tsx | 288 +++++++++++++++++++++++++++++++ src/pages/InvitationPage.tsx | 49 +++++- src/pages/SocialContractPage.tsx | 8 + 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/pages/GroupJoinPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 18c65d9..9a01342 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import { OnboardingProvider } from './context/OnboardingContext'; import DashboardLayout from './components/layout/DashboardLayout'; import SocialContractPage from './pages/SocialContractPage'; +import GroupJoinPage from './pages/GroupJoinPage'; import ImportPage from './pages/ImportPage'; import ContactListPage from './pages/ContactListPage'; import ContactViewPage from './pages/ContactViewPage'; @@ -28,6 +29,8 @@ function App() { {/* Social Contract page - outside DashboardLayout */} } /> + {/* Group Join page for existing members - outside DashboardLayout */} + } /> {/* Main app routes - inside DashboardLayout */} { if ((fromInvite || newMember) && groupData) { // Mark as visited and open AI assistant directly 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 // Clean up URL parameters after showing the welcome @@ -113,6 +124,8 @@ const GroupDetailPage = () => { newSearchParams.delete('newMember'); newSearchParams.delete('firstName'); newSearchParams.delete('inviteeName'); + newSearchParams.delete('existingMember'); + newSearchParams.delete('rCard'); setSearchParams(newSearchParams); } } diff --git a/src/pages/GroupJoinPage.tsx b/src/pages/GroupJoinPage.tsx new file mode 100644 index 0000000..cc56fa4 --- /dev/null +++ b/src/pages/GroupJoinPage.tsx @@ -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(null); + const [selectedRCard, setSelectedRCard] = useState(''); + const [inviterName, setInviterName] = useState(''); + 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 = { + Business: , + PersonOutline: , + Groups: , + FamilyRestroom: , + Favorite: , + Home: , + }; + return iconMap[iconName] || ; + }; + + 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 ( + + + Loading... + + + ); + } + + if (!group) { + return ( + + + Group not found + + + ); + } + + return ( + + + {/* Back Button */} + + + + + {/* Header */} + + + Join {group.name} + + + + Choose your relationship type + + + + {inviterName} has invited you to join {group.name}. + Select how you'd like to connect with this group. This determines what personal information will be visible to group members. + + + + {/* Group Info */} + + + {group.name.charAt(0)} + + + + {group.name} + + {group.isPrivate && ( + + )} + + + + {/* rCard Selection */} + + + + Select Your Relationship Type + + + + {DEFAULT_RCARDS.map((rCard) => ( + 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], + }, + }} + > + + + {getRCardIcon(rCard.icon || 'PersonOutline')} + + + + {rCard.name} + + + + {rCard.description} + + + {selectedRCard === rCard.name && ( + + )} + + + ))} + + + + {/* Privacy Info */} + + + Privacy Note: Your selected relationship type determines: + + +
  • What profile information is visible to group members
  • +
  • How you appear in group member directories
  • +
  • What data is shared for group activities and collaboration
  • +
    +
    + + {/* Action Button */} + +
    +
    + ); +}; + +export default GroupJoinPage; \ No newline at end of file diff --git a/src/pages/InvitationPage.tsx b/src/pages/InvitationPage.tsx index 58a2a5a..6b4016d 100644 --- a/src/pages/InvitationPage.tsx +++ b/src/pages/InvitationPage.tsx @@ -14,7 +14,9 @@ import { TextField, InputAdornment, Avatar, - Chip + Chip, + FormControlLabel, + Switch } from '@mui/material'; import { Share, @@ -38,6 +40,7 @@ const InvitationPage = () => { inviterName?: string; relationshipType?: string; }>({}); + const [isExistingMember, setIsExistingMember] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const [invitationId, setInvitationId] = useState(''); const [group, setGroup] = useState(null); @@ -80,6 +83,7 @@ const InvitationPage = () => { ...(inviteeName && { inviteeName }), ...(inviterName && { inviterName }), ...(relationshipType && { relationshipType }), + ...(isExistingMember && { existingMember: 'true' }), }); const url = `${window.location.origin}/onboarding?${urlParams.toString()}`; @@ -196,6 +200,7 @@ const InvitationPage = () => { ...(inviteeName && { inviteeName }), ...(inviterName && { inviterName }), ...(relationshipType && { relationshipType }), + ...(isExistingMember && { existingMember: 'true' }), }); const url = `${window.location.origin}/onboarding?${urlParams.toString()}`; @@ -342,6 +347,48 @@ const InvitationPage = () => { Invitation ID: {invitationId} + {/* Existing Member Toggle */} + + { + 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' }} + /> + + {isExistingMember + ? "They'll choose how to connect with this group using their existing profile" + : "They'll need to create a new NAO account first" + } + + + diff --git a/src/pages/SocialContractPage.tsx b/src/pages/SocialContractPage.tsx index 1b4226b..ce5c0bd 100644 --- a/src/pages/SocialContractPage.tsx +++ b/src/pages/SocialContractPage.tsx @@ -48,6 +48,14 @@ const SocialContractPage = () => { const loadGroupData = async () => { const groupId = searchParams.get('groupId'); 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 const inviteeName = searchParams.get('inviteeName');