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:
36
maternal-web/.gitignore
vendored
Normal file
36
maternal-web/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
maternal-web/README.md
Normal file
36
maternal-web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
221
maternal-web/app/(auth)/login/page.tsx
Normal file
221
maternal-web/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
maternal-web/app/(auth)/onboarding/page.tsx
Normal file
248
maternal-web/app/(auth)/onboarding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
maternal-web/app/(auth)/register/page.tsx
Normal file
268
maternal-web/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
maternal-web/app/favicon.ico
Normal file
BIN
maternal-web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
33
maternal-web/app/globals.css
Normal file
33
maternal-web/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
220
maternal-web/app/history/page.tsx
Normal file
220
maternal-web/app/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
maternal-web/app/layout.tsx
Normal file
45
maternal-web/app/layout.tsx
Normal 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
127
maternal-web/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
maternal-web/app/track/diaper/page.tsx
Normal file
264
maternal-web/app/track/diaper/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
maternal-web/app/track/feeding/page.tsx
Normal file
254
maternal-web/app/track/feeding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
maternal-web/app/track/sleep/page.tsx
Normal file
259
maternal-web/app/track/sleep/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
maternal-web/components/ThemeRegistry.tsx
Normal file
21
maternal-web/components/ThemeRegistry.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
|
||||
import { maternalTheme } from '@/styles/themes/maternalTheme';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function ThemeRegistry({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AppRouterCacheProvider>
|
||||
<ThemeProvider theme={maternalTheme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</AppRouterCacheProvider>
|
||||
);
|
||||
}
|
||||
41
maternal-web/components/common/ProtectedRoute.tsx
Normal file
41
maternal-web/components/common/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password'];
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
41
maternal-web/components/layouts/AppShell/AppShell.tsx
Normal file
41
maternal-web/components/layouts/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Container } from '@mui/material';
|
||||
import { MobileNav } from '../MobileNav/MobileNav';
|
||||
import { TabBar } from '../TabBar/TabBar';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppShell = ({ children }: AppShellProps) => {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isTablet = useMediaQuery('(max-width: 1024px)');
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
pb: isMobile ? '64px' : 0, // Space for tab bar
|
||||
}}>
|
||||
{!isMobile && <MobileNav />}
|
||||
|
||||
<Container
|
||||
maxWidth={isTablet ? 'md' : 'lg'}
|
||||
sx={{
|
||||
flex: 1,
|
||||
px: isMobile ? 2 : 3,
|
||||
py: 3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
{isMobile && <TabBar />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
114
maternal-web/components/layouts/MobileNav/MobileNav.tsx
Normal file
114
maternal-web/components/layouts/MobileNav/MobileNav.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Typography,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home,
|
||||
Timeline,
|
||||
Chat,
|
||||
Insights,
|
||||
Settings,
|
||||
ChildCare,
|
||||
Group,
|
||||
Logout,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export const MobileNav = () => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', icon: <Home />, path: '/' },
|
||||
{ label: 'Track Activity', icon: <Timeline />, path: '/track' },
|
||||
{ label: 'AI Assistant', icon: <Chat />, path: '/ai-assistant' },
|
||||
{ label: 'Insights', icon: <Insights />, path: '/insights' },
|
||||
{ label: 'Children', icon: <ChildCare />, path: '/children' },
|
||||
{ label: 'Family', icon: <Group />, path: '/family' },
|
||||
{ label: 'Settings', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
router.push(path);
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static" elevation={1} sx={{ bgcolor: 'background.paper' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="primary"
|
||||
aria-label="menu"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, color: 'primary.main', fontWeight: 600 }}>
|
||||
Maternal
|
||||
</Typography>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>U</Avatar>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<Box
|
||||
sx={{ width: 280 }}
|
||||
role="presentation"
|
||||
>
|
||||
<Box sx={{ p: 3, bgcolor: 'primary.light' }}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: 'primary.main', mb: 2 }}>U</Avatar>
|
||||
<Typography variant="h6" fontWeight="600">User Name</Typography>
|
||||
<Typography variant="body2" color="text.secondary">user@example.com</Typography>
|
||||
</Box>
|
||||
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.path} disablePadding>
|
||||
<ListItemButton onClick={() => handleNavigate(item.path)}>
|
||||
<ListItemIcon sx={{ color: 'primary.main' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => handleNavigate('/logout')}>
|
||||
<ListItemIcon sx={{ color: 'error.main' }}>
|
||||
<Logout />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Logout" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
maternal-web/components/layouts/TabBar/TabBar.tsx
Normal file
63
maternal-web/components/layouts/TabBar/TabBar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
|
||||
import {
|
||||
Home,
|
||||
Timeline,
|
||||
Chat,
|
||||
Insights,
|
||||
Settings,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export const TabBar = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Home', icon: <Home />, value: '/' },
|
||||
{ label: 'Track', icon: <Timeline />, value: '/track' },
|
||||
{ label: 'AI Chat', icon: <Chat />, value: '/ai-assistant' },
|
||||
{ label: 'Insights', icon: <Insights />, value: '/insights' },
|
||||
{ label: 'Settings', icon: <Settings />, value: '/settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
elevation={3}
|
||||
>
|
||||
<BottomNavigation
|
||||
value={pathname}
|
||||
onChange={(event, newValue) => {
|
||||
router.push(newValue);
|
||||
}}
|
||||
showLabels
|
||||
sx={{
|
||||
height: 64,
|
||||
'& .MuiBottomNavigationAction-root': {
|
||||
minWidth: 60,
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<BottomNavigationAction
|
||||
key={tab.value}
|
||||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
value={tab.value}
|
||||
/>
|
||||
))}
|
||||
</BottomNavigation>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
19
maternal-web/hooks/useMediaQuery.ts
Normal file
19
maternal-web/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener('change', listener);
|
||||
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
66
maternal-web/lib/api/client.ts
Normal file
66
maternal-web/lib/api/client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If error is 401 and we haven't tried to refresh yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const { accessToken } = response.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
208
maternal-web/lib/auth/AuthContext.tsx
Normal file
208
maternal-web/lib/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/auth/me');
|
||||
setUser(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/api/v1/auth/login', {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
deviceInfo,
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { user, tokens } }
|
||||
const { data: responseData } = response.data;
|
||||
const { tokens, user: userData } = responseData;
|
||||
|
||||
localStorage.setItem('accessToken', tokens.accessToken);
|
||||
localStorage.setItem('refreshToken', tokens.refreshToken);
|
||||
setUser(userData);
|
||||
|
||||
router.push('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login failed:', error);
|
||||
throw new Error(error.response?.data?.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: RegisterData) => {
|
||||
try {
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/api/v1/auth/register', {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
deviceInfo,
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { user, family, tokens } }
|
||||
const { data: responseData } = response.data;
|
||||
const { tokens, user: userData } = responseData;
|
||||
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = tokens;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(userData);
|
||||
|
||||
// Redirect to onboarding
|
||||
router.push('/onboarding');
|
||||
} catch (error: any) {
|
||||
console.error('Registration failed:', error);
|
||||
throw new Error(error.response?.data?.message || error.message || 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
setUser(null);
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/auth/me');
|
||||
setUser(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple device fingerprint
|
||||
function generateDeviceFingerprint(): string {
|
||||
const navigator = window.navigator;
|
||||
const screen = window.screen;
|
||||
|
||||
const data = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
screen.colorDepth,
|
||||
screen.width,
|
||||
screen.height,
|
||||
new Date().getTimezoneOffset(),
|
||||
].join('|');
|
||||
|
||||
// Simple hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
return hash.toString(36);
|
||||
}
|
||||
140
maternal-web/lib/services/trackingService.ts
Normal file
140
maternal-web/lib/services/trackingService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export interface FeedingData {
|
||||
childId: string;
|
||||
type: 'breast_left' | 'breast_right' | 'breast_both' | 'bottle' | 'solid';
|
||||
duration?: number;
|
||||
amount?: number;
|
||||
unit?: 'ml' | 'oz';
|
||||
notes?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface SleepData {
|
||||
childId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DiaperData {
|
||||
childId: string;
|
||||
type: 'wet' | 'dirty' | 'both' | 'clean';
|
||||
timestamp: string;
|
||||
rash: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
childId: string;
|
||||
type: 'feeding' | 'sleep' | 'diaper';
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
feedingCount: number;
|
||||
sleepHours: number;
|
||||
diaperCount: number;
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
class TrackingService {
|
||||
async logFeeding(data: FeedingData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'feeding',
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
data: {
|
||||
feedingType: data.type,
|
||||
duration: data.duration,
|
||||
amount: data.amount,
|
||||
unit: data.unit,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async logSleep(data: SleepData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'sleep',
|
||||
timestamp: data.startTime,
|
||||
data: {
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
quality: data.quality,
|
||||
duration: this.calculateDuration(data.startTime, data.endTime),
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async logDiaper(data: DiaperData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'diaper',
|
||||
timestamp: data.timestamp,
|
||||
data: {
|
||||
diaperType: data.type,
|
||||
rash: data.rash,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getActivities(childId: string, filters?: {
|
||||
type?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}): Promise<Activity[]> {
|
||||
const params = new URLSearchParams({
|
||||
childId,
|
||||
...filters,
|
||||
} as Record<string, string>);
|
||||
|
||||
const response = await apiClient.get(`/api/v1/activities?${params.toString()}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getActivityById(activityId: string): Promise<Activity> {
|
||||
const response = await apiClient.get(`/api/v1/activities/${activityId}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateActivity(activityId: string, data: Partial<Activity>): Promise<Activity> {
|
||||
const response = await apiClient.patch(`/api/v1/activities/${activityId}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async deleteActivity(activityId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/activities/${activityId}`);
|
||||
}
|
||||
|
||||
async getDailySummary(childId: string, date?: string): Promise<DailySummary> {
|
||||
const params = new URLSearchParams({
|
||||
childId,
|
||||
date: date || new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
const response = await apiClient.get(`/api/v1/activities/daily-summary?${params.toString()}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
private calculateDuration(startTime: string, endTime: string): number {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 1000 / 60); // duration in minutes
|
||||
}
|
||||
}
|
||||
|
||||
export const trackingService = new TrackingService();
|
||||
30
maternal-web/next.config.mjs
Normal file
30
maternal-web/next.config.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import withPWA from 'next-pwa';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['api.maternalapp.com', 'localhost'],
|
||||
},
|
||||
};
|
||||
|
||||
const pwaConfig = withPWA({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https?.*/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'offlineCache',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default pwaConfig(nextConfig);
|
||||
8584
maternal-web/package-lock.json
generated
Normal file
8584
maternal-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
maternal-web/package.json
Normal file
41
maternal-web/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "maternal-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3030",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^5.18.0",
|
||||
"@mui/material": "^5.18.0",
|
||||
"@mui/material-nextjs": "^7.3.2",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"next": "14.2.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
maternal-web/postcss.config.mjs
Normal file
8
maternal-web/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
26
maternal-web/public/manifest.json
Normal file
26
maternal-web/public/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Maternal Organization App",
|
||||
"short_name": "Maternal",
|
||||
"description": "AI-powered maternal and child care organization",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFF9F5",
|
||||
"theme_color": "#FFB6C1",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["health", "lifestyle", "productivity"],
|
||||
"lang": "en-US"
|
||||
}
|
||||
1
maternal-web/public/next.svg
Normal file
1
maternal-web/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
maternal-web/public/vercel.svg
Normal file
1
maternal-web/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
123
maternal-web/styles/themes/maternalTheme.ts
Normal file
123
maternal-web/styles/themes/maternalTheme.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const maternalTheme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FFB6C1', // Light pink/rose
|
||||
light: '#FFE4E1', // Misty rose
|
||||
dark: '#DB7093', // Pale violet red
|
||||
},
|
||||
secondary: {
|
||||
main: '#FFDAB9', // Peach puff
|
||||
light: '#FFE5CC',
|
||||
dark: '#FFB347', // Deep peach
|
||||
},
|
||||
background: {
|
||||
default: '#FFF9F5', // Warm white
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#2D3748',
|
||||
secondary: '#718096',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 24,
|
||||
textTransform: 'none',
|
||||
minHeight: 48, // Touch target size
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
},
|
||||
sizeLarge: {
|
||||
minHeight: 56,
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiInputBase-root': {
|
||||
minHeight: 48,
|
||||
borderRadius: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
elevation1: {
|
||||
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
elevation2: {
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
elevation3: {
|
||||
boxShadow: '0 6px 30px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
20
maternal-web/tailwind.config.ts
Normal file
20
maternal-web/tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
26
maternal-web/tsconfig.json
Normal file
26
maternal-web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user