Add Phase 2 & 3: Web frontend with authentication and tracking features

- Initialize Next.js 14 web application with Material UI and TypeScript
- Implement authentication (login/register) with device fingerprint
- Create mobile-first responsive layout with app shell pattern
- Add tracking pages for feeding, sleep, and diaper changes
- Implement activity history with filtering
- Configure backend CORS for web frontend (port 3030)
- Update backend port to 3020, frontend to 3030
- Fix API response handling for auth endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andupetcu
2025-09-30 21:21:22 +03:00
parent 1de21044d6
commit 37227369d3
32 changed files with 11584 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
TextField,
Button,
Typography,
Paper,
InputAdornment,
IconButton,
Divider,
Alert,
CircularProgress,
Link as MuiLink,
} from '@mui/material';
import { Visibility, VisibilityOff, Google, Apple } from '@mui/icons-material';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import * as z from 'zod';
import { useAuth } from '@/lib/auth/AuthContext';
import Link from 'next/link';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
setError(null);
setIsLoading(true);
try {
await login(data);
// Navigation is handled in the login function
} catch (err: any) {
setError(err.message || 'Failed to login. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
px: 3,
py: 6,
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 4,
maxWidth: 440,
mx: 'auto',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Typography
variant="h4"
gutterBottom
align="center"
fontWeight="600"
color="primary.main"
>
Welcome Back 👋
</Typography>
<Typography
variant="body2"
align="center"
color="text.secondary"
sx={{ mb: 3 }}
>
Sign in to continue tracking your child's journey
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
error={!!errors.email}
helperText={errors.email?.message}
{...register('email')}
disabled={isLoading}
inputProps={{ autoComplete: 'username' }}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
margin="normal"
error={!!errors.password}
helperText={errors.password?.message}
{...register('password')}
disabled={isLoading}
inputProps={{ autoComplete: 'current-password' }}
InputProps={{
sx: { borderRadius: 3 },
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
disabled={isLoading}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Box sx={{ textAlign: 'right', mt: 1 }}>
<Link href="/forgot-password" passHref legacyBehavior>
<MuiLink variant="body2" sx={{ cursor: 'pointer' }}>
Forgot password?
</MuiLink>
</Link>
</Box>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Sign In'
)}
</Button>
</Box>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
OR
</Typography>
</Divider>
<Button
fullWidth
variant="outlined"
startIcon={<Google />}
size="large"
disabled={isLoading}
sx={{ mb: 2 }}
>
Continue with Google
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<Apple />}
size="large"
disabled={isLoading}
>
Continue with Apple
</Button>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Don't have an account?{' '}
<Link href="/register" passHref legacyBehavior>
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
Sign up
</MuiLink>
</Link>
</Typography>
</Box>
</Paper>
</motion.div>
</Box>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useState } from 'react';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
TextField,
Avatar,
IconButton,
Alert,
} from '@mui/material';
import { ArrowBack, ArrowForward, Check } from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
const steps = ['Welcome', 'Add Child', 'Invite Family', 'Notifications'];
export default function OnboardingPage() {
const [activeStep, setActiveStep] = useState(0);
const [childName, setChildName] = useState('');
const [childBirthDate, setChildBirthDate] = useState('');
const router = useRouter();
const handleNext = () => {
if (activeStep === steps.length - 1) {
// Complete onboarding
router.push('/');
} else {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleSkip = () => {
router.push('/');
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
px: 3,
py: 4,
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 600,
mx: 'auto',
width: '100%',
p: 4,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeStep === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h4" gutterBottom fontWeight="600" color="primary.main">
Welcome to Maternal! 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mt: 2, mb: 4 }}>
We're excited to help you track and understand your child's development, sleep patterns, feeding schedules, and more.
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
<Typography variant="h6" fontWeight="600">📊</Typography>
<Typography variant="body2">Track Activities</Typography>
</Paper>
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
<Typography variant="h6" fontWeight="600">🤖</Typography>
<Typography variant="body2">AI Insights</Typography>
</Paper>
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
<Typography variant="h6" fontWeight="600">👨👩👧</Typography>
<Typography variant="body2">Family Sharing</Typography>
</Paper>
</Box>
</Box>
)}
{activeStep === 1 && (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="600">
Add Your First Child
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Let's start by adding some basic information about your child.
</Typography>
<TextField
fullWidth
label="Child's Name"
value={childName}
onChange={(e) => setChildName(e.target.value)}
margin="normal"
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
label="Birth Date"
type="date"
value={childBirthDate}
onChange={(e) => setChildBirthDate(e.target.value)}
margin="normal"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
You can add more children and details later from settings.
</Alert>
</Box>
)}
{activeStep === 2 && (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="600">
Invite Family Members
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Share your child's progress with family members. They can view activities and add their own entries.
</Typography>
<TextField
fullWidth
label="Email Address"
type="email"
margin="normal"
placeholder="partner@example.com"
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<Button
variant="outlined"
fullWidth
sx={{ mt: 2 }}
>
Send Invitation
</Button>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
You can skip this step and invite family members later.
</Alert>
</Box>
)}
{activeStep === 3 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'primary.main',
mx: 'auto',
mb: 3,
}}
>
<Check sx={{ fontSize: 48 }} />
</Avatar>
<Typography variant="h5" gutterBottom fontWeight="600">
You're All Set! 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
Start tracking your child's activities and get personalized insights.
</Typography>
<Paper sx={{ p: 3, bgcolor: 'primary.light', mb: 3 }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Next Steps:
</Typography>
<Typography variant="body2" align="left" component="div">
• Track your first feeding, sleep, or diaper change<br />
• Chat with our AI assistant for parenting tips<br />
• Explore insights and predictions based on your data
</Typography>
</Paper>
</Box>
)}
</motion.div>
</AnimatePresence>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
onClick={handleBack}
disabled={activeStep === 0}
startIcon={<ArrowBack />}
>
Back
</Button>
<Box sx={{ flex: 1 }} />
{activeStep < steps.length - 1 && activeStep > 0 && (
<Button onClick={handleSkip} sx={{ mr: 2 }}>
Skip
</Button>
)}
<Button
variant="contained"
onClick={handleNext}
endIcon={activeStep === steps.length - 1 ? <Check /> : <ArrowForward />}
>
{activeStep === steps.length - 1 ? 'Get Started' : 'Next'}
</Button>
</Box>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,268 @@
'use client';
import { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Paper,
InputAdornment,
IconButton,
Alert,
CircularProgress,
Link as MuiLink,
Checkbox,
FormControlLabel,
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import * as z from 'zod';
import { useAuth } from '@/lib/auth/AuthContext';
import Link from 'next/link';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine(val => val === true, {
message: 'You must agree to the terms and conditions',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { register: registerUser } = useAuth();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
setError(null);
setIsLoading(true);
try {
await registerUser({
name: data.name,
email: data.email,
password: data.password,
});
// Navigation to onboarding is handled in the register function
} catch (err: any) {
setError(err.message || 'Failed to register. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
px: 3,
py: 6,
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 4,
maxWidth: 440,
mx: 'auto',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Typography
variant="h4"
gutterBottom
align="center"
fontWeight="600"
color="primary.main"
>
Create Account
</Typography>
<Typography
variant="body2"
align="center"
color="text.secondary"
sx={{ mb: 3 }}
>
Start your journey to organized parenting
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<TextField
fullWidth
label="Full Name"
margin="normal"
error={!!errors.name}
helperText={errors.name?.message}
{...register('name')}
disabled={isLoading}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
error={!!errors.email}
helperText={errors.email?.message}
{...register('email')}
disabled={isLoading}
inputProps={{ autoComplete: 'username' }}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
margin="normal"
error={!!errors.password}
helperText={errors.password?.message}
{...register('password')}
disabled={isLoading}
inputProps={{ autoComplete: 'new-password' }}
InputProps={{
sx: { borderRadius: 3 },
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
disabled={isLoading}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
margin="normal"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword?.message}
{...register('confirmPassword')}
disabled={isLoading}
inputProps={{ autoComplete: 'new-password' }}
InputProps={{
sx: { borderRadius: 3 },
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
disabled={isLoading}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<FormControlLabel
control={
<Checkbox
{...register('agreeToTerms')}
disabled={isLoading}
/>
}
label={
<Typography variant="body2" color="text.secondary">
I agree to the{' '}
<MuiLink href="/terms" target="_blank">
Terms of Service
</MuiLink>{' '}
and{' '}
<MuiLink href="/privacy" target="_blank">
Privacy Policy
</MuiLink>
</Typography>
}
sx={{ mt: 2 }}
/>
{errors.agreeToTerms && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
{errors.agreeToTerms.message}
</Typography>
)}
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Create Account'
)}
</Button>
</Box>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link href="/login" passHref legacyBehavior>
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
Sign in
</MuiLink>
</Link>
</Typography>
</Box>
</Paper>
</motion.div>
</Box>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useState } from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Chip,
IconButton,
Tabs,
Tab,
Button,
} from '@mui/material';
import {
Restaurant,
Hotel,
BabyChangingStation,
Delete,
Edit,
FilterList,
} from '@mui/icons-material';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { motion } from 'framer-motion';
import { formatDistanceToNow } from 'date-fns';
// Mock data - will be replaced with API calls
const mockActivities = [
{
id: '1',
type: 'feeding',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
details: 'Breast feeding - Left, 15 minutes',
icon: <Restaurant />,
color: '#FFB6C1',
},
{
id: '2',
type: 'diaper',
timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000),
details: 'Diaper change - Wet',
icon: <BabyChangingStation />,
color: '#FFE4B5',
},
{
id: '3',
type: 'sleep',
timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000),
details: 'Sleep - 2h 30m, Good quality',
icon: <Hotel />,
color: '#B6D7FF',
},
{
id: '4',
type: 'feeding',
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000),
details: 'Bottle - 120ml',
icon: <Restaurant />,
color: '#FFB6C1',
},
{
id: '5',
type: 'diaper',
timestamp: new Date(Date.now() - 7 * 60 * 60 * 1000),
details: 'Diaper change - Both',
icon: <BabyChangingStation />,
color: '#FFE4B5',
},
];
export default function HistoryPage() {
const [filter, setFilter] = useState<string>('all');
const [activities, setActivities] = useState(mockActivities);
const filteredActivities =
filter === 'all'
? activities
: activities.filter((activity) => activity.type === filter);
const handleDelete = (id: string) => {
// TODO: Call API to delete activity
setActivities(activities.filter((activity) => activity.id !== id));
};
return (
<ProtectedRoute>
<AppShell>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" fontWeight="600">
Activity History
</Typography>
<IconButton>
<FilterList />
</IconButton>
</Box>
{/* Filter Tabs */}
<Paper sx={{ mb: 3 }}>
<Tabs
value={filter}
onChange={(_, newValue) => setFilter(newValue)}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="All" value="all" />
<Tab label="Feeding" value="feeding" icon={<Restaurant />} iconPosition="start" />
<Tab label="Sleep" value="sleep" icon={<Hotel />} iconPosition="start" />
<Tab label="Diaper" value="diaper" icon={<BabyChangingStation />} iconPosition="start" />
</Tabs>
</Paper>
{/* Activity Timeline */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Paper>
<List>
{filteredActivities.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No activities found
</Typography>
</Box>
) : (
filteredActivities.map((activity, index) => (
<motion.div
key={activity.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<ListItem
sx={{
borderBottom: index < filteredActivities.length - 1 ? '1px solid' : 'none',
borderColor: 'divider',
py: 2,
}}
secondaryAction={
<Box>
<IconButton edge="end" aria-label="edit" sx={{ mr: 1 }}>
<Edit />
</IconButton>
<IconButton
edge="end"
aria-label="delete"
onClick={() => handleDelete(activity.id)}
>
<Delete />
</IconButton>
</Box>
}
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: activity.color }}>
{activity.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={activity.details}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
</Typography>
<Chip
label={activity.type}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
textTransform: 'capitalize',
}}
/>
</Box>
}
/>
</ListItem>
</motion.div>
))
)}
</List>
</Paper>
</motion.div>
{/* Daily Summary */}
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" fontWeight="600" gutterBottom>
Today's Summary
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 2 }}>
<Chip
icon={<Restaurant />}
label={`${activities.filter((a) => a.type === 'feeding').length} Feedings`}
sx={{ bgcolor: '#FFB6C1', color: 'white' }}
/>
<Chip
icon={<Hotel />}
label={`${activities.filter((a) => a.type === 'sleep').length} Sleep Sessions`}
sx={{ bgcolor: '#B6D7FF', color: 'white' }}
/>
<Chip
icon={<BabyChangingStation />}
label={`${activities.filter((a) => a.type === 'diaper').length} Diaper Changes`}
sx={{ bgcolor: '#FFE4B5', color: 'white' }}
/>
</Box>
</Paper>
</Box>
</AppShell>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { ThemeRegistry } from '@/components/ThemeRegistry';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Maternal - AI-Powered Child Care Assistant',
description: 'Track, analyze, and get AI-powered insights for your child\'s development, sleep, feeding, and more.',
manifest: '/manifest.json',
themeColor: '#FFB6C1',
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Maternal',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#FFB6C1" />
<link rel="apple-touch-icon" href="/icon-192x192.png" />
</head>
<body className={inter.className}>
<ThemeRegistry>
{children}
</ThemeRegistry>
</body>
</html>
);
}

127
maternal-web/app/page.tsx Normal file
View File

@@ -0,0 +1,127 @@
'use client';
import { Box, Typography, Button, Paper, Grid } from '@mui/material';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import {
Restaurant,
Hotel,
BabyChangingStation,
Insights,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useAuth } from '@/lib/auth/AuthContext';
import { useRouter } from 'next/navigation';
export default function HomePage() {
const { user } = useAuth();
const router = useRouter();
const quickActions = [
{ icon: <Restaurant />, label: 'Feeding', color: '#FFB6C1', path: '/track/feeding' },
{ icon: <Hotel />, label: 'Sleep', color: '#B6D7FF', path: '/track/sleep' },
{ icon: <BabyChangingStation />, label: 'Diaper', color: '#FFE4B5', path: '/track/diaper' },
{ icon: <Insights />, label: 'Insights', color: '#E6E6FA', path: '/insights' },
];
return (
<ProtectedRoute>
<AppShell>
<Box>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Typography variant="h4" gutterBottom fontWeight="600" sx={{ mb: 1 }}>
Welcome Back{user?.name ? `, ${user.name}` : ''}! 👋
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
Track your child's activities and get AI-powered insights
</Typography>
{/* Quick Actions */}
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
Quick Actions
</Typography>
<Grid container spacing={2} sx={{ mb: 4 }}>
{quickActions.map((action, index) => (
<Grid item xs={6} sm={3} key={action.label}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Paper
onClick={() => router.push(action.path)}
sx={{
p: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: action.color,
color: 'white',
transition: 'transform 0.2s',
'&:hover': {
transform: 'scale(1.05)',
},
}}
>
<Box sx={{ fontSize: 48, mb: 1 }}>{action.icon}</Box>
<Typography variant="body1" fontWeight="600">
{action.label}
</Typography>
</Paper>
</motion.div>
</Grid>
))}
</Grid>
{/* Recent Activity */}
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
Today's Summary
</Typography>
<Paper sx={{ p: 3 }}>
<Grid container spacing={3}>
<Grid item xs={4}>
<Box textAlign="center">
<Restaurant sx={{ fontSize: 32, color: 'primary.main', mb: 1 }} />
<Typography variant="h5" fontWeight="600">8</Typography>
<Typography variant="body2" color="text.secondary">Feedings</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box textAlign="center">
<Hotel sx={{ fontSize: 32, color: 'info.main', mb: 1 }} />
<Typography variant="h5" fontWeight="600">12h</Typography>
<Typography variant="body2" color="text.secondary">Sleep</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box textAlign="center">
<BabyChangingStation sx={{ fontSize: 32, color: 'warning.main', mb: 1 }} />
<Typography variant="h5" fontWeight="600">6</Typography>
<Typography variant="body2" color="text.secondary">Diapers</Typography>
</Box>
</Grid>
</Grid>
</Paper>
{/* Next Predicted Activity */}
<Box sx={{ mt: 4 }}>
<Paper sx={{ p: 3, bgcolor: 'primary.light' }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Next Predicted Activity
</Typography>
<Typography variant="h6" fontWeight="600" gutterBottom>
Nap time in 45 minutes
</Typography>
<Typography variant="body2" color="text.secondary">
Based on your child's sleep patterns
</Typography>
</Paper>
</Box>
</motion.div>
</Box>
</AppShell>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
TextField,
IconButton,
Alert,
Chip,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
ToggleButtonGroup,
ToggleButton,
} from '@mui/material';
import {
ArrowBack,
Save,
Mic,
BabyChangingStation,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { motion } from 'framer-motion';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { format } from 'date-fns';
const diaperSchema = z.object({
type: z.enum(['wet', 'dirty', 'both', 'clean']),
timestamp: z.string(),
rash: z.boolean(),
notes: z.string().optional(),
});
type DiaperFormData = z.infer<typeof diaperSchema>;
export default function DiaperTrackPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
control,
formState: { errors },
} = useForm<DiaperFormData>({
resolver: zodResolver(diaperSchema),
defaultValues: {
type: 'wet',
rash: false,
timestamp: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
},
});
const diaperType = watch('type');
const rash = watch('rash');
const setTimeNow = () => {
const now = format(new Date(), "yyyy-MM-dd'T'HH:mm");
setValue('timestamp', now);
};
const onSubmit = async (data: DiaperFormData) => {
setError(null);
try {
// TODO: Call API to save diaper data
console.log('Diaper data:', data);
setSuccess(true);
setTimeout(() => router.push('/'), 2000);
} catch (err: any) {
setError(err.message || 'Failed to log diaper change');
}
};
return (
<ProtectedRoute>
<AppShell>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Typography variant="h4" fontWeight="600">
Track Diaper Change
</Typography>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Diaper change logged successfully!
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Paper sx={{ p: 3, mb: 3 }}>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
{/* Icon Header */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<BabyChangingStation sx={{ fontSize: 64, color: 'primary.main' }} />
</Box>
{/* Time */}
<Box sx={{ mb: 3 }}>
<FormLabel sx={{ mb: 1, display: 'block' }}>Time</FormLabel>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<TextField
fullWidth
type="datetime-local"
{...register('timestamp')}
error={!!errors.timestamp}
helperText={errors.timestamp?.message}
InputLabelProps={{ shrink: true }}
/>
<Button variant="outlined" onClick={setTimeNow} sx={{ minWidth: 100 }}>
Now
</Button>
</Box>
</Box>
{/* Diaper Type */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
Diaper Type
</FormLabel>
<Controller
name="type"
control={control}
render={({ field }) => (
<ToggleButtonGroup
{...field}
exclusive
fullWidth
onChange={(_, value) => {
if (value !== null) {
field.onChange(value);
}
}}
>
<ToggleButton value="wet" sx={{ py: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h5">💧</Typography>
<Typography variant="body2">Wet</Typography>
</Box>
</ToggleButton>
<ToggleButton value="dirty" sx={{ py: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h5">💩</Typography>
<Typography variant="body2">Dirty</Typography>
</Box>
</ToggleButton>
<ToggleButton value="both" sx={{ py: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h5">💧💩</Typography>
<Typography variant="body2">Both</Typography>
</Box>
</ToggleButton>
<ToggleButton value="clean" sx={{ py: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h5"></Typography>
<Typography variant="body2">Clean</Typography>
</Box>
</ToggleButton>
</ToggleButtonGroup>
)}
/>
</FormControl>
{/* Rash Indicator */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
Diaper Rash?
</FormLabel>
<RadioGroup row>
<FormControlLabel
value="no"
control={
<Radio
checked={!rash}
onChange={() => setValue('rash', false)}
/>
}
label="No"
/>
<FormControlLabel
value="yes"
control={
<Radio
checked={rash}
onChange={() => setValue('rash', true)}
/>
}
label="Yes"
/>
</RadioGroup>
</FormControl>
{/* Rash Warning */}
{rash && (
<Alert severity="warning" sx={{ mb: 3 }}>
Consider applying diaper rash cream and consulting your pediatrician if it persists.
</Alert>
)}
{/* Notes */}
<TextField
fullWidth
label="Notes (optional)"
multiline
rows={3}
{...register('notes')}
sx={{ mb: 3 }}
placeholder="Color, consistency, or any concerns..."
/>
{/* Voice Input Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Chip
icon={<Mic />}
label="Use Voice Input"
onClick={() => {/* TODO: Implement voice input */}}
sx={{ cursor: 'pointer' }}
/>
</Box>
{/* Submit Button */}
<Button
fullWidth
type="submit"
variant="contained"
size="large"
startIcon={<Save />}
>
Save Diaper Change
</Button>
</Box>
</Paper>
</motion.div>
</Box>
</AppShell>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,254 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Paper,
TextField,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
IconButton,
Alert,
Chip,
} from '@mui/material';
import {
ArrowBack,
PlayArrow,
Stop,
Save,
Mic,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const feedingSchema = z.object({
type: z.enum(['breast_left', 'breast_right', 'breast_both', 'bottle', 'solid']),
amount: z.number().min(0).optional(),
unit: z.enum(['ml', 'oz']).optional(),
notes: z.string().optional(),
});
type FeedingFormData = z.infer<typeof feedingSchema>;
export default function FeedingTrackPage() {
const router = useRouter();
const [isTimerRunning, setIsTimerRunning] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FeedingFormData>({
resolver: zodResolver(feedingSchema),
defaultValues: {
type: 'breast_left',
unit: 'ml',
},
});
const feedingType = watch('type');
useEffect(() => {
let interval: NodeJS.Timeout;
if (isTimerRunning) {
interval = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [isTimerRunning]);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const onSubmit = async (data: FeedingFormData) => {
setError(null);
try {
// TODO: Call API to save feeding data
console.log('Feeding data:', { ...data, duration });
setSuccess(true);
setTimeout(() => router.push('/'), 2000);
} catch (err: any) {
setError(err.message || 'Failed to log feeding');
}
};
return (
<ProtectedRoute>
<AppShell>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Typography variant="h4" fontWeight="600">
Track Feeding
</Typography>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Feeding logged successfully!
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Paper sx={{ p: 3, mb: 3 }}>
{/* Timer Section */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h2" fontWeight="600" sx={{ mb: 2 }}>
{formatDuration(duration)}
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
{!isTimerRunning ? (
<Button
variant="contained"
size="large"
startIcon={<PlayArrow />}
onClick={() => setIsTimerRunning(true)}
>
Start Timer
</Button>
) : (
<Button
variant="contained"
color="error"
size="large"
startIcon={<Stop />}
onClick={() => setIsTimerRunning(false)}
>
Stop Timer
</Button>
)}
</Box>
</Box>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
{/* Feeding Type */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
Feeding Type
</FormLabel>
<RadioGroup row>
<FormControlLabel
value="breast_left"
control={<Radio {...register('type')} />}
label="Left Breast"
/>
<FormControlLabel
value="breast_right"
control={<Radio {...register('type')} />}
label="Right Breast"
/>
<FormControlLabel
value="breast_both"
control={<Radio {...register('type')} />}
label="Both"
/>
<FormControlLabel
value="bottle"
control={<Radio {...register('type')} />}
label="Bottle"
/>
<FormControlLabel
value="solid"
control={<Radio {...register('type')} />}
label="Solid Food"
/>
</RadioGroup>
</FormControl>
{/* Amount (for bottle/solid) */}
{(feedingType === 'bottle' || feedingType === 'solid') && (
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<TextField
fullWidth
label="Amount"
type="number"
{...register('amount', { valueAsNumber: true })}
error={!!errors.amount}
helperText={errors.amount?.message}
/>
<FormControl sx={{ minWidth: 120 }}>
<RadioGroup row>
<FormControlLabel
value="ml"
control={<Radio {...register('unit')} />}
label="ml"
/>
<FormControlLabel
value="oz"
control={<Radio {...register('unit')} />}
label="oz"
/>
</RadioGroup>
</FormControl>
</Box>
)}
{/* Notes */}
<TextField
fullWidth
label="Notes (optional)"
multiline
rows={3}
{...register('notes')}
sx={{ mb: 3 }}
/>
{/* Voice Input Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Chip
icon={<Mic />}
label="Use Voice Input"
onClick={() => {/* TODO: Implement voice input */}}
sx={{ cursor: 'pointer' }}
/>
</Box>
{/* Submit Button */}
<Button
fullWidth
type="submit"
variant="contained"
size="large"
startIcon={<Save />}
>
Save Feeding
</Button>
</Box>
</Paper>
</motion.div>
</Box>
</AppShell>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,259 @@
'use client';
import { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
TextField,
IconButton,
Alert,
Chip,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
} from '@mui/material';
import {
ArrowBack,
Bedtime,
WbSunny,
Save,
Mic,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { format } from 'date-fns';
const sleepSchema = z.object({
startTime: z.string(),
endTime: z.string(),
quality: z.enum(['excellent', 'good', 'fair', 'poor']),
notes: z.string().optional(),
}).refine((data) => new Date(data.endTime) > new Date(data.startTime), {
message: 'End time must be after start time',
path: ['endTime'],
});
type SleepFormData = z.infer<typeof sleepSchema>;
export default function SleepTrackPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<SleepFormData>({
resolver: zodResolver(sleepSchema),
defaultValues: {
quality: 'good',
},
});
const startTime = watch('startTime');
const endTime = watch('endTime');
const calculateDuration = () => {
if (!startTime || !endTime) return null;
const start = new Date(startTime);
const end = new Date(endTime);
const diff = end.getTime() - start.getTime();
if (diff < 0) return null;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
return `${hours}h ${minutes}m`;
};
const setStartNow = () => {
const now = format(new Date(), "yyyy-MM-dd'T'HH:mm");
setValue('startTime', now);
};
const setEndNow = () => {
const now = format(new Date(), "yyyy-MM-dd'T'HH:mm");
setValue('endTime', now);
};
const onSubmit = async (data: SleepFormData) => {
setError(null);
try {
// TODO: Call API to save sleep data
console.log('Sleep data:', data);
setSuccess(true);
setTimeout(() => router.push('/'), 2000);
} catch (err: any) {
setError(err.message || 'Failed to log sleep');
}
};
return (
<ProtectedRoute>
<AppShell>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Typography variant="h4" fontWeight="600">
Track Sleep
</Typography>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Sleep logged successfully!
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Paper sx={{ p: 3, mb: 3 }}>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
{/* Start Time */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Bedtime color="primary" />
<Typography variant="h6" fontWeight="600">
Sleep Start
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<TextField
fullWidth
type="datetime-local"
{...register('startTime')}
error={!!errors.startTime}
helperText={errors.startTime?.message}
InputLabelProps={{ shrink: true }}
/>
<Button variant="outlined" onClick={setStartNow} sx={{ minWidth: 100 }}>
Now
</Button>
</Box>
</Box>
{/* End Time */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<WbSunny color="warning" />
<Typography variant="h6" fontWeight="600">
Wake Up
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<TextField
fullWidth
type="datetime-local"
{...register('endTime')}
error={!!errors.endTime}
helperText={errors.endTime?.message}
InputLabelProps={{ shrink: true }}
/>
<Button variant="outlined" onClick={setEndNow} sx={{ minWidth: 100 }}>
Now
</Button>
</Box>
</Box>
{/* Duration Display */}
{calculateDuration() && (
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Chip
label={`Duration: ${calculateDuration()}`}
color="primary"
sx={{ fontSize: '1rem', py: 3 }}
/>
</Box>
)}
{/* Sleep Quality */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
Sleep Quality
</FormLabel>
<RadioGroup row>
<FormControlLabel
value="excellent"
control={<Radio {...register('quality')} />}
label="Excellent"
/>
<FormControlLabel
value="good"
control={<Radio {...register('quality')} />}
label="Good"
/>
<FormControlLabel
value="fair"
control={<Radio {...register('quality')} />}
label="Fair"
/>
<FormControlLabel
value="poor"
control={<Radio {...register('quality')} />}
label="Poor"
/>
</RadioGroup>
</FormControl>
{/* Notes */}
<TextField
fullWidth
label="Notes (optional)"
multiline
rows={3}
{...register('notes')}
sx={{ mb: 3 }}
placeholder="Any disruptions, dreams, or observations..."
/>
{/* Voice Input Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Chip
icon={<Mic />}
label="Use Voice Input"
onClick={() => {/* TODO: Implement voice input */}}
sx={{ cursor: 'pointer' }}
/>
</Box>
{/* Submit Button */}
<Button
fullWidth
type="submit"
variant="contained"
size="large"
startIcon={<Save />}
>
Save Sleep Session
</Button>
</Box>
</Paper>
</motion.div>
</Box>
</AppShell>
</ProtectedRoute>
);
}