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

36
maternal-web/.gitignore vendored Normal file
View 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
View 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.

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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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;
};

View 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;

View 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);
}

View 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();

View 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

File diff suppressed because it is too large Load Diff

41
maternal-web/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View 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"
}

View 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

View 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

View 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,
},
},
},
},
});

View 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;

View 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"]
}