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

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

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

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

View File

@@ -0,0 +1,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>
);
}