Files
maternal-app/maternal-web/app/(auth)/onboarding/page.tsx
Andrei 95ef0e5e78
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
docs: Add comprehensive multi-child implementation plan
Added detailed implementation plan covering:
- Frontend: Dynamic UI, child selector, bulk activity logging, comparison analytics
- Backend: Bulk operations, multi-child queries, family statistics
- AI/Voice: Child name detection, context building, clarification flows
- Database: Schema enhancements, user preferences, bulk operation tracking
- State management, API enhancements, real-time sync updates
- Testing strategy: Unit, integration, and E2E tests
- Migration plan with feature flags for phased rollout
- Performance optimizations: Caching, indexes, code splitting

Also includes:
- Security fixes for multi-family data leakage in analytics pages
- ParentFlow branding updates
- Activity tracking navigation improvements
- Backend DTO and error handling fixes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 21:05:14 +00:00

583 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
TextField,
Avatar,
IconButton,
Alert,
CircularProgress,
MenuItem,
Card,
CardActionArea,
CardContent,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
Grid,
StepConnector,
stepConnectorClasses,
styled,
} from '@mui/material';
import { ArrowBack, ArrowForward, Check, Language, Straighten } from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth/AuthContext';
import { childrenApi } from '@/lib/api/children';
import { useLocale, MeasurementSystem } from '@/hooks/useLocale';
import { useTranslation } from '@/hooks/useTranslation';
import { supportedLanguages } from '@/lib/i18n/config';
import { usersApi } from '@/lib/api/users';
import { useTheme } from '@mui/material/styles';
import { StepIconProps } from '@mui/material/StepIcon';
const steps = ['Welcome', 'Language', 'Measurements', 'Add Child', 'Complete'];
// Custom connector for mobile-friendly stepper
const CustomConnector = styled(StepConnector)(({ theme }) => ({
[`&.${stepConnectorClasses.active}`]: {
[`& .${stepConnectorClasses.line}`]: {
borderColor: theme.palette.primary.main,
},
},
[`&.${stepConnectorClasses.completed}`]: {
[`& .${stepConnectorClasses.line}`]: {
borderColor: theme.palette.primary.main,
},
},
[`& .${stepConnectorClasses.line}`]: {
borderColor: theme.palette.divider,
borderTopWidth: 2,
borderRadius: 1,
},
}));
// Custom step icon showing numbers
const CustomStepIconRoot = styled('div')<{ ownerState: { active?: boolean; completed?: boolean } }>(
({ theme, ownerState }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: ownerState.completed
? theme.palette.primary.main
: ownerState.active
? theme.palette.primary.main
: theme.palette.grey[300],
color: ownerState.active || ownerState.completed ? '#fff' : theme.palette.text.secondary,
fontWeight: 600,
fontSize: '0.875rem',
})
);
function CustomStepIcon(props: StepIconProps) {
const { active, completed, icon } = props;
return (
<CustomStepIconRoot ownerState={{ active, completed }}>
{completed ? <Check sx={{ fontSize: 18 }} /> : icon}
</CustomStepIconRoot>
);
}
export default function OnboardingPage() {
const [activeStep, setActiveStep] = useState(0);
const [selectedLanguage, setSelectedLanguage] = useState('en');
const [selectedMeasurement, setSelectedMeasurement] = useState<MeasurementSystem>('metric');
const [childName, setChildName] = useState('');
const [childBirthDate, setChildBirthDate] = useState('');
const [childGender, setChildGender] = useState<'male' | 'female' | 'other'>('other');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const { user, refreshUser } = useAuth();
const { setLanguage, setMeasurementSystem } = useLocale();
const { t } = useTranslation('onboarding');
const theme = useTheme();
const handleNext = async () => {
setError('');
// Step 1: Save language preference
if (activeStep === 1) {
try {
setLoading(true);
await setLanguage(selectedLanguage);
// Save to backend
if (user?.id) {
await usersApi.updatePreferences({
language: selectedLanguage,
});
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} catch (err: any) {
console.error('Failed to save language:', err);
// Language was saved locally even if backend failed, so continue
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} finally {
setLoading(false);
}
return;
}
// Step 2: Save measurement preference
if (activeStep === 2) {
try {
setLoading(true);
setMeasurementSystem(selectedMeasurement);
// Save to backend
if (user?.id) {
await usersApi.updatePreferences({
measurementUnit: selectedMeasurement,
});
await refreshUser();
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} catch (err: any) {
console.error('Failed to save measurement:', err);
// Measurement was saved locally even if backend failed, so continue
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} finally {
setLoading(false);
}
return;
}
// Step 3: Validate and save child data (Add Child)
if (activeStep === 3) {
if (!childName.trim() || !childBirthDate) {
setError(t('child.name') + ' and ' + t('child.dateOfBirth') + ' are required');
return;
}
const familyId = user?.families?.[0]?.familyId;
if (!familyId) {
console.error('No family found. User object:', JSON.stringify(user, null, 2));
setError('Unable to create child profile. Your account setup is incomplete. Please contact support or try logging out and back in.');
return;
}
try {
setLoading(true);
setError('');
await childrenApi.createChild(familyId, {
name: childName.trim(),
birthDate: childBirthDate,
gender: childGender,
});
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} catch (err: any) {
console.error('Failed to create child:', err);
setError(err.response?.data?.message || 'Failed to save child. Please try again.');
} finally {
setLoading(false);
}
return;
}
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, ${theme.palette.primary.light} 0%, ${theme.palette.secondary.light} 100%)`,
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 600,
mx: 'auto',
width: '100%',
p: { xs: 3, sm: 4 },
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Stepper
activeStep={activeStep}
alternativeLabel
connector={<CustomConnector />}
sx={{ mb: 4 }}
>
{steps.map((label, index) => {
let stepLabel = label;
if (index === 0) stepLabel = t('welcome.title').split('!')[0];
else if (index === 1) stepLabel = t('language.title');
else if (index === 2) stepLabel = t('measurements.title');
else if (index === 3) stepLabel = t('child.title');
else if (index === 4) stepLabel = t('complete.title').split('!')[0];
// Only show label for active step on mobile
const showLabel = activeStep === index;
return (
<Step key={label}>
<StepLabel
StepIconComponent={CustomStepIcon}
sx={{
'& .MuiStepLabel-label': {
display: { xs: showLabel ? 'block' : 'none', sm: 'block' },
mt: 1,
fontSize: { xs: '0.75rem', sm: '0.875rem' },
},
}}
>
{stepLabel}
</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 }}
>
{/* Step 0: Welcome */}
{activeStep === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h4" gutterBottom fontWeight="600" color="primary.main">
{t('welcome.title')} 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mt: 2, mb: 4 }}>
{t('welcome.description')}
</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">{t('welcome.getStarted')}</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>
)}
{/* Step 1: Language Selection */}
{activeStep === 1 && (
<Box sx={{ py: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Language sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
<Box>
<Typography variant="h5" fontWeight="600">
{t('language.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('language.subtitle')}
</Typography>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<Grid container spacing={2} sx={{ mt: 2 }}>
{supportedLanguages.map((lang) => (
<Grid item xs={12} sm={6} key={lang.code}>
<Card
sx={{
border: selectedLanguage === lang.code ? '2px solid' : '1px solid',
borderColor: selectedLanguage === lang.code ? 'primary.main' : 'divider',
transition: 'all 0.2s',
}}
>
<CardActionArea
onClick={() => setSelectedLanguage(lang.code)}
sx={{ p: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h6" fontWeight="600">
{lang.nativeName}
</Typography>
<Typography variant="body2" color="text.secondary">
{lang.name}
</Typography>
</Box>
<Radio
checked={selectedLanguage === lang.code}
value={lang.code}
name="language-radio"
/>
</Box>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
{t('language.description')}
</Alert>
</Box>
)}
{/* Step 2: Measurement System */}
{activeStep === 2 && (
<Box sx={{ py: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Straighten sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
<Box>
<Typography variant="h5" fontWeight="600">
{t('measurements.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('measurements.subtitle')}
</Typography>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}>
<Card
sx={{
border: selectedMeasurement === 'metric' ? '2px solid' : '1px solid',
borderColor: selectedMeasurement === 'metric' ? 'primary.main' : 'divider',
height: '100%',
}}
>
<CardActionArea
onClick={() => setSelectedMeasurement('metric')}
sx={{ p: 3, height: '100%' }}
>
<Box sx={{ textAlign: 'center' }}>
<Radio
checked={selectedMeasurement === 'metric'}
value="metric"
name="measurement-radio"
sx={{ mb: 1 }}
/>
<Typography variant="h6" fontWeight="600" gutterBottom>
{t('measurements.metric.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('measurements.metric.description')}
</Typography>
</Box>
</CardActionArea>
</Card>
</Grid>
<Grid item xs={12} sm={6}>
<Card
sx={{
border: selectedMeasurement === 'imperial' ? '2px solid' : '1px solid',
borderColor: selectedMeasurement === 'imperial' ? 'primary.main' : 'divider',
height: '100%',
}}
>
<CardActionArea
onClick={() => setSelectedMeasurement('imperial')}
sx={{ p: 3, height: '100%' }}
>
<Box sx={{ textAlign: 'center' }}>
<Radio
checked={selectedMeasurement === 'imperial'}
value="imperial"
name="measurement-radio"
sx={{ mb: 1 }}
/>
<Typography variant="h6" fontWeight="600" gutterBottom>
{t('measurements.imperial.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('measurements.imperial.description')}
</Typography>
</Box>
</CardActionArea>
</Card>
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
{t('measurements.description')}
</Alert>
</Box>
)}
{/* Step 3: Add Child */}
{activeStep === 3 && (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="600">
{t('child.title')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('child.subtitle')}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label={t('child.name')}
value={childName}
onChange={(e) => setChildName(e.target.value)}
margin="normal"
required
disabled={loading}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
label={t('child.dateOfBirth')}
type="date"
value={childBirthDate}
onChange={(e) => setChildBirthDate(e.target.value)}
margin="normal"
required
disabled={loading}
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<TextField
fullWidth
select
label={t('child.gender')}
value={childGender}
onChange={(e) => setChildGender(e.target.value as 'male' | 'female' | 'other')}
margin="normal"
disabled={loading}
InputProps={{
sx: { borderRadius: 3 },
}}
>
<MenuItem value="male">{t('child.genders.male')}</MenuItem>
<MenuItem value="female">{t('child.genders.female')}</MenuItem>
<MenuItem value="other">{t('child.genders.preferNotToSay')}</MenuItem>
</TextField>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
{t('child.skipForNow')}
</Alert>
</Box>
)}
{/* Step 4: Complete */}
{activeStep === 4 && (
<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">
{t('complete.title')} 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{t('complete.description')}
</Typography>
<Paper sx={{ p: 3, bgcolor: 'primary.light', mb: 3 }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
{t('complete.subtitle')}
</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 />}
>
{t('navigation.back')}
</Button>
<Box sx={{ flex: 1 }} />
{activeStep === 3 && (
<Button onClick={handleSkip} sx={{ mr: 2 }}>
{t('navigation.skip')}
</Button>
)}
<Button
variant="contained"
onClick={handleNext}
disabled={loading}
endIcon={loading ? <CircularProgress size={20} /> : (activeStep === steps.length - 1 ? <Check /> : <ArrowForward />)}
>
{activeStep === steps.length - 1 ? t('complete.startTracking') : t('navigation.next')}
</Button>
</Box>
</Paper>
</Box>
);
}