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 { useAuth } from '@/lib/auth/AuthContext';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -93,12 +95,16 @@ export default function AIAssistantPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
height: 'calc(100vh - 200px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -326,5 +332,7 @@ export default function AIAssistantPage() {
|
||||
</Typography>
|
||||
</Paper>
|
||||
</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