feat: Add multi-family support with family selector UI
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
- Created useSelectedFamily hook for managing family selection across app - Added family selector dropdown to family page when user has multiple families - Family selection persists in localStorage - Fixed bug where users could only see their first family - Updated dev port from 3005 to 3030 in package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,12 @@ import {
|
|||||||
ListItemText,
|
ListItemText,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { PersonAdd, ContentCopy, People, Delete, GroupAdd } from '@mui/icons-material';
|
import { PersonAdd, ContentCopy, People, Delete, GroupAdd, Home } from '@mui/icons-material';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
@@ -32,10 +36,12 @@ import { JoinFamilyDialog } from '@/components/family/JoinFamilyDialog';
|
|||||||
import { RemoveMemberDialog } from '@/components/family/RemoveMemberDialog';
|
import { RemoveMemberDialog } from '@/components/family/RemoveMemberDialog';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
|
||||||
|
|
||||||
export default function FamilyPage() {
|
export default function FamilyPage() {
|
||||||
const { t } = useTranslation('family');
|
const { t } = useTranslation('family');
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
|
const { familyId, selectedIndex, setSelectedIndex, userFamilies, hasMultipleFamilies } = useSelectedFamily();
|
||||||
const [family, setFamily] = useState<Family | null>(null);
|
const [family, setFamily] = useState<Family | null>(null);
|
||||||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
const [members, setMembers] = useState<FamilyMember[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -46,9 +52,7 @@ export default function FamilyPage() {
|
|||||||
const [memberToRemove, setMemberToRemove] = useState<FamilyMember | null>(null);
|
const [memberToRemove, setMemberToRemove] = useState<FamilyMember | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [snackbar, setSnackbar] = useState({ open: false, message: '' });
|
const [snackbar, setSnackbar] = useState({ open: false, message: '' });
|
||||||
|
const [familyNames, setFamilyNames] = useState<Record<string, string>>({});
|
||||||
// Get familyId from user
|
|
||||||
const familyId = user?.families?.[0]?.familyId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (familyId) {
|
if (familyId) {
|
||||||
@@ -57,7 +61,7 @@ export default function FamilyPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(t('messages.noFamilyFound'));
|
setError(t('messages.noFamilyFound'));
|
||||||
}
|
}
|
||||||
}, [familyId]);
|
}, [familyId, selectedIndex]);
|
||||||
|
|
||||||
const fetchFamilyData = async () => {
|
const fetchFamilyData = async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
@@ -71,6 +75,12 @@ export default function FamilyPage() {
|
|||||||
]);
|
]);
|
||||||
setFamily(familyData);
|
setFamily(familyData);
|
||||||
setMembers(membersData);
|
setMembers(membersData);
|
||||||
|
|
||||||
|
// Cache the family name
|
||||||
|
setFamilyNames(prev => ({
|
||||||
|
...prev,
|
||||||
|
[familyId]: familyData.name
|
||||||
|
}));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch family data:', err);
|
console.error('Failed to fetch family data:', err);
|
||||||
setError(err.response?.data?.message || t('messages.failedToLoad'));
|
setError(err.response?.data?.message || t('messages.failedToLoad'));
|
||||||
@@ -90,6 +100,37 @@ export default function FamilyPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateShareCode = async () => {
|
||||||
|
if (!familyId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const result = await familiesApi.generateShareCode(familyId);
|
||||||
|
|
||||||
|
// Update family with new code
|
||||||
|
if (family) {
|
||||||
|
setFamily({ ...family, shareCode: result.code });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
await navigator.clipboard.writeText(result.code);
|
||||||
|
|
||||||
|
const expiryDate = new Date(result.expiresAt).toLocaleDateString();
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: `New share code generated: ${result.code} (expires ${expiryDate}). Copied to clipboard!`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to generate share code:', err);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: err.response?.data?.message || 'Failed to generate share code',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInviteMember = async (data: InviteMemberData) => {
|
const handleInviteMember = async (data: InviteMemberData) => {
|
||||||
if (!familyId) {
|
if (!familyId) {
|
||||||
throw new Error(t('messages.noFamilyId'));
|
throw new Error(t('messages.noFamilyId'));
|
||||||
@@ -200,6 +241,40 @@ export default function FamilyPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Family Selector - show if user has multiple families */}
|
||||||
|
{hasMultipleFamilies && (
|
||||||
|
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Home sx={{ color: 'primary.main' }} />
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Select Family</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedIndex}
|
||||||
|
label="Select Family"
|
||||||
|
onChange={(e) => setSelectedIndex(Number(e.target.value))}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{userFamilies.map((fam, index) => (
|
||||||
|
<MenuItem key={fam.familyId} value={index}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||||
|
<Typography sx={{ flex: 1 }}>
|
||||||
|
{familyNames[fam.familyId] || `Family ${index + 1}`}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={fam.role}
|
||||||
|
size="small"
|
||||||
|
color={fam.role === 'parent' ? 'primary' : 'default'}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||||
{error}
|
{error}
|
||||||
@@ -222,7 +297,7 @@ export default function FamilyPage() {
|
|||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{t('shareCode.description')}
|
{t('shareCode.description')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={family.shareCode}
|
label={family.shareCode}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -243,6 +318,15 @@ export default function FamilyPage() {
|
|||||||
{t('buttons.copyCode')}
|
{t('buttons.copyCode')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleGenerateShareCode}
|
||||||
|
disabled={actionLoading}
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Generate New Code
|
||||||
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
47
maternal-web/hooks/useSelectedFamily.ts
Normal file
47
maternal-web/hooks/useSelectedFamily.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
|
const SELECTED_FAMILY_KEY = 'selectedFamilyIndex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage selected family across the app
|
||||||
|
* Stores selection in localStorage for persistence
|
||||||
|
*/
|
||||||
|
export function useSelectedFamily() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const userFamilies = user?.families || [];
|
||||||
|
|
||||||
|
// Initialize from localStorage or default to 0
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(() => {
|
||||||
|
if (typeof window === 'undefined') return 0;
|
||||||
|
const stored = localStorage.getItem(SELECTED_FAMILY_KEY);
|
||||||
|
return stored ? parseInt(stored, 10) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure selectedIndex is within bounds
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex >= userFamilies.length && userFamilies.length > 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}, [userFamilies.length, selectedIndex]);
|
||||||
|
|
||||||
|
// Save to localStorage when changed
|
||||||
|
const setSelectedFamily = (index: number) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(SELECTED_FAMILY_KEY, index.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const familyId = userFamilies[selectedIndex]?.familyId || null;
|
||||||
|
const familyRole = userFamilies[selectedIndex]?.role || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
familyId,
|
||||||
|
familyRole,
|
||||||
|
selectedIndex,
|
||||||
|
setSelectedIndex: setSelectedFamily,
|
||||||
|
userFamilies,
|
||||||
|
hasMultipleFamilies: userFamilies.length > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3005 -H 0.0.0.0",
|
"dev": "next dev -p 3030 -H 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
Reference in New Issue
Block a user