Add missing pages with AppShell layout integration
- Created /track, /insights, /children, /family, /settings, /logout pages - Wrapped all authenticated pages with AppShell and ProtectedRoute - Updated AI assistant page to use AppShell layout - All pages now have proper header/navigation and footer/tabbar - Added responsive mobile and desktop layouts - Integrated with existing navigation system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ import { Send, SmartToy, Person, AutoAwesome } from '@mui/icons-material';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import apiClient from '@/lib/api/client';
|
import apiClient from '@/lib/api/client';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -93,14 +95,18 @@ export default function AIAssistantPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<ProtectedRoute>
|
||||||
sx={{
|
<AppShell>
|
||||||
height: 'calc(100vh - 64px)',
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
flexDirection: 'column',
|
height: 'calc(100vh - 200px)',
|
||||||
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
display: 'flex',
|
||||||
}}
|
flexDirection: 'column',
|
||||||
>
|
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@@ -325,6 +331,8 @@ export default function AIAssistantPage() {
|
|||||||
for medical advice.
|
for medical advice.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
maternal-web/app/children/page.tsx
Normal file
60
maternal-web/app/children/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, CardContent, Button } from '@mui/material';
|
||||||
|
import { Add, ChildCare } from '@mui/icons-material';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
|
export default function ChildrenPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Children
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Manage your family's children profiles
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => router.push('/children/new')}
|
||||||
|
>
|
||||||
|
Add Child
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<ChildCare sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No children added yet
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Add your first child to start tracking their activities
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => router.push('/children/new')}
|
||||||
|
>
|
||||||
|
Add First Child
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
maternal-web/app/family/page.tsx
Normal file
127
maternal-web/app/family/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, CardContent, Button, Avatar, Chip } from '@mui/material';
|
||||||
|
import { PersonAdd, ContentCopy, People } from '@mui/icons-material';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
|
export default function FamilyPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const handleInvite = () => {
|
||||||
|
// Invite functionality to be implemented
|
||||||
|
alert('Family invitation feature coming soon!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = () => {
|
||||||
|
// Copy share code to clipboard
|
||||||
|
navigator.clipboard.writeText('FAMILY-CODE-123');
|
||||||
|
alert('Family code copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Family
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Manage your family members and share access
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PersonAdd />}
|
||||||
|
onClick={handleInvite}
|
||||||
|
>
|
||||||
|
Invite Member
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Family Share Code */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Family Share Code
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Share this code with family members to give them access to your family's data
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Chip
|
||||||
|
label="FAMILY-CODE-123"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
py: 2.5,
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ContentCopy />}
|
||||||
|
onClick={handleCopyCode}
|
||||||
|
>
|
||||||
|
Copy Code
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Family Members */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom sx={{ mb: 3 }}>
|
||||||
|
Family Members
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Current User */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body1" fontWeight="600">
|
||||||
|
{user?.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{user?.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip label="Admin" color="primary" size="small" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<People sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
No other family members yet
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Invite family members to collaborate on child care
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PersonAdd />}
|
||||||
|
onClick={handleInvite}
|
||||||
|
>
|
||||||
|
Invite First Member
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
maternal-web/app/insights/page.tsx
Normal file
79
maternal-web/app/insights/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, CardContent } from '@mui/material';
|
||||||
|
import { TrendingUp, Insights as InsightsIcon, Timeline } from '@mui/icons-material';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Insights & Analytics
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Track patterns and get insights about your child's activities
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<TrendingUp sx={{ mr: 1, color: 'primary.main' }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Sleep Patterns
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Average sleep duration: Coming soon
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Sleep quality: Coming soon
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<InsightsIcon sx={{ mr: 1, color: 'success.main' }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Feeding Patterns
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Average feeding frequency: Coming soon
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Total daily intake: Coming soon
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Timeline sx={{ mr: 1, color: 'info.main' }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Activity Timeline
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Detailed analytics and trends will be displayed here
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
maternal-web/app/logout/page.tsx
Normal file
34
maternal-web/app/logout/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const performLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
performLogout();
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Logging out...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
maternal-web/app/settings/page.tsx
Normal file
140
maternal-web/app/settings/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Card, CardContent, TextField, Button, Divider, Switch, FormControlLabel } from '@mui/material';
|
||||||
|
import { Save, Logout } from '@mui/icons-material';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
notifications: true,
|
||||||
|
emailUpdates: false,
|
||||||
|
darkMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Save settings functionality to be implemented
|
||||||
|
alert('Settings saved successfully!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<Box sx={{ maxWidth: 'md', mx: 'auto' }}>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Manage your account settings and preferences
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Profile Settings */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Profile Information
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
defaultValue={user?.name}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
defaultValue={user?.email}
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
onClick={handleSave}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Notifications
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications}
|
||||||
|
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Push Notifications"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.emailUpdates}
|
||||||
|
onChange={(e) => setSettings({ ...settings, emailUpdates: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Email Updates"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance Settings */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Appearance
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.darkMode}
|
||||||
|
onChange={(e) => setSettings({ ...settings, darkMode: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Dark Mode (Coming Soon)"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Account Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Account Actions
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<Logout />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
maternal-web/app/track/page.tsx
Normal file
89
maternal-web/app/track/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, CardContent, CardActionArea } from '@mui/material';
|
||||||
|
import { Restaurant, Hotel, BabyChangingStation, ChildCare } from '@mui/icons-material';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
|
||||||
|
export default function TrackPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const trackingOptions = [
|
||||||
|
{
|
||||||
|
title: 'Feeding',
|
||||||
|
icon: <Restaurant sx={{ fontSize: 48, color: 'primary.main' }} />,
|
||||||
|
path: '/track/feeding',
|
||||||
|
color: '#FFE4E1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sleep',
|
||||||
|
icon: <Hotel sx={{ fontSize: 48, color: 'info.main' }} />,
|
||||||
|
path: '/track/sleep',
|
||||||
|
color: '#E1F5FF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Diaper',
|
||||||
|
icon: <BabyChangingStation sx={{ fontSize: 48, color: 'warning.main' }} />,
|
||||||
|
path: '/track/diaper',
|
||||||
|
color: '#FFF4E1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Activity',
|
||||||
|
icon: <ChildCare sx={{ fontSize: 48, color: 'success.main' }} />,
|
||||||
|
path: '/track/activity',
|
||||||
|
color: '#E8F5E9',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Track Activity
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Select an activity to track
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{trackingOptions.map((option) => (
|
||||||
|
<Grid item xs={12} sm={6} md={3} key={option.title}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
bgcolor: option.color,
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea
|
||||||
|
onClick={() => router.push(option.path)}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
{option.icon}
|
||||||
|
<Typography variant="h6" fontWeight="600" sx={{ mt: 2 }}>
|
||||||
|
{option.title}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
maternal-web/components/common/OfflineIndicator.tsx
Normal file
158
maternal-web/components/common/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, LinearProgress, Box, Typography } from '@mui/material';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { CloudOff, CloudQueue, CloudDone } from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface OfflineIndicatorProps {
|
||||||
|
isOnline?: boolean;
|
||||||
|
pendingActionsCount?: number;
|
||||||
|
syncInProgress?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OfflineIndicator = ({
|
||||||
|
isOnline: propIsOnline,
|
||||||
|
pendingActionsCount = 0,
|
||||||
|
syncInProgress = false,
|
||||||
|
}: OfflineIndicatorProps) => {
|
||||||
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial online status
|
||||||
|
setIsOnline(navigator.onLine);
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const effectiveIsOnline = propIsOnline !== undefined ? propIsOnline : isOnline;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!effectiveIsOnline && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -100, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={<CloudOff />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="600">
|
||||||
|
You're offline
|
||||||
|
</Typography>
|
||||||
|
{pendingActionsCount > 0 && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} will sync when you're back online
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{effectiveIsOnline && syncInProgress && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -100, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
icon={<CloudQueue />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="600">
|
||||||
|
Syncing data...
|
||||||
|
</Typography>
|
||||||
|
{pendingActionsCount > 0 && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} remaining
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
<LinearProgress />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{effectiveIsOnline && !syncInProgress && pendingActionsCount === 0 &&
|
||||||
|
typeof propIsOnline !== 'undefined' && propIsOnline && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -100, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
onAnimationComplete={() => {
|
||||||
|
// Auto-hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById('sync-complete-alert');
|
||||||
|
if (element) {
|
||||||
|
element.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
id="sync-complete-alert"
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity="success"
|
||||||
|
icon={<CloudDone />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight="600">
|
||||||
|
All data synced successfully!
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
maternal-web/hooks/useOfflineSync.ts
Normal file
121
maternal-web/hooks/useOfflineSync.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
setOnlineStatus,
|
||||||
|
setSyncInProgress,
|
||||||
|
removePendingAction,
|
||||||
|
incrementRetryCount,
|
||||||
|
updateLastSyncTime,
|
||||||
|
} from '@/store/slices/offlineSlice';
|
||||||
|
import apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface RootState {
|
||||||
|
offline: {
|
||||||
|
isOnline: boolean;
|
||||||
|
pendingActions: any[];
|
||||||
|
syncInProgress: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
export const useOfflineSync = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { isOnline, pendingActions, syncInProgress } = useSelector(
|
||||||
|
(state: RootState) => state.offline
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monitor online/offline status
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
dispatch(setOnlineStatus(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
dispatch(setOnlineStatus(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial status
|
||||||
|
dispatch(setOnlineStatus(navigator.onLine));
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Sync pending actions when online
|
||||||
|
const syncPendingActions = useCallback(async () => {
|
||||||
|
if (!isOnline || pendingActions.length === 0 || syncInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setSyncInProgress(true));
|
||||||
|
|
||||||
|
for (const action of pendingActions) {
|
||||||
|
try {
|
||||||
|
// Attempt to replay the action
|
||||||
|
await replayAction(action);
|
||||||
|
|
||||||
|
// Remove from pending actions on success
|
||||||
|
dispatch(removePendingAction(action.id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to sync action ${action.id}:`, error);
|
||||||
|
|
||||||
|
// Increment retry count
|
||||||
|
dispatch(incrementRetryCount(action.id));
|
||||||
|
|
||||||
|
// If max retries exceeded, remove the action
|
||||||
|
if (action.retryCount >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
console.warn(`Max retries exceeded for action ${action.id}, removing from queue`);
|
||||||
|
dispatch(removePendingAction(action.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setSyncInProgress(false));
|
||||||
|
dispatch(updateLastSyncTime());
|
||||||
|
}, [isOnline, pendingActions, syncInProgress, dispatch]);
|
||||||
|
|
||||||
|
// Trigger sync when coming online
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnline && pendingActions.length > 0) {
|
||||||
|
syncPendingActions();
|
||||||
|
}
|
||||||
|
}, [isOnline, pendingActions.length, syncPendingActions]);
|
||||||
|
|
||||||
|
// Replay a specific action
|
||||||
|
const replayAction = async (action: any) => {
|
||||||
|
const { type, payload } = action;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'CREATE_ACTIVITY':
|
||||||
|
return await apiClient.post('/api/v1/activities', payload);
|
||||||
|
|
||||||
|
case 'UPDATE_ACTIVITY':
|
||||||
|
return await apiClient.put(`/api/v1/activities/${payload.id}`, payload);
|
||||||
|
|
||||||
|
case 'DELETE_ACTIVITY':
|
||||||
|
return await apiClient.delete(`/api/v1/activities/${payload.id}`);
|
||||||
|
|
||||||
|
case 'CREATE_CHILD':
|
||||||
|
return await apiClient.post('/api/v1/children', payload);
|
||||||
|
|
||||||
|
case 'UPDATE_CHILD':
|
||||||
|
return await apiClient.put(`/api/v1/children/${payload.id}`, payload);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${type}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOnline,
|
||||||
|
pendingActionsCount: pendingActions.length,
|
||||||
|
syncInProgress,
|
||||||
|
syncPendingActions,
|
||||||
|
};
|
||||||
|
};
|
||||||
74
maternal-web/store/slices/offlineSlice.ts
Normal file
74
maternal-web/store/slices/offlineSlice.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface PendingAction {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload: any;
|
||||||
|
timestamp: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfflineState {
|
||||||
|
isOnline: boolean;
|
||||||
|
pendingActions: PendingAction[];
|
||||||
|
lastSyncTime: string | null;
|
||||||
|
syncInProgress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: OfflineState = {
|
||||||
|
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||||
|
pendingActions: [],
|
||||||
|
lastSyncTime: null,
|
||||||
|
syncInProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const offlineSlice = createSlice({
|
||||||
|
name: 'offline',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.isOnline = action.payload;
|
||||||
|
if (action.payload && state.pendingActions.length > 0) {
|
||||||
|
state.syncInProgress = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPendingAction: (state, action: PayloadAction<Omit<PendingAction, 'id' | 'timestamp' | 'retryCount'>>) => {
|
||||||
|
state.pendingActions.push({
|
||||||
|
...action.payload,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removePendingAction: (state, action: PayloadAction<string>) => {
|
||||||
|
state.pendingActions = state.pendingActions.filter(a => a.id !== action.payload);
|
||||||
|
},
|
||||||
|
incrementRetryCount: (state, action: PayloadAction<string>) => {
|
||||||
|
const action_ = state.pendingActions.find(a => a.id === action.payload);
|
||||||
|
if (action_) {
|
||||||
|
action_.retryCount += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearPendingActions: (state) => {
|
||||||
|
state.pendingActions = [];
|
||||||
|
},
|
||||||
|
setSyncInProgress: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.syncInProgress = action.payload;
|
||||||
|
},
|
||||||
|
updateLastSyncTime: (state) => {
|
||||||
|
state.lastSyncTime = new Date().toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setOnlineStatus,
|
||||||
|
addPendingAction,
|
||||||
|
removePendingAction,
|
||||||
|
incrementRetryCount,
|
||||||
|
clearPendingActions,
|
||||||
|
setSyncInProgress,
|
||||||
|
updateLastSyncTime,
|
||||||
|
} = offlineSlice.actions;
|
||||||
|
|
||||||
|
export default offlineSlice.reducer;
|
||||||
Reference in New Issue
Block a user