Files
maternal-app/maternal-web/app/(auth)/onboarding/page.tsx
Andrei 40dbb2287a
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
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
feat: Implement comprehensive onboarding improvements with role-based family invites
This commit adds a complete onboarding improvements system including progress
tracking, streamlined UI, and role-based family invitation system.

## Backend Changes

### Database Migrations
- Add onboarding tracking fields to users table (onboarding_completed, onboarding_step, onboarding_data)
- Add role-based invite codes to families table (parent/caregiver/viewer codes with expiration)
- Add indexes for fast invite code lookups

### User Preferences Module
- Add UserPreferencesController with onboarding endpoints
- Add UserPreferencesService with progress tracking methods
- Add UpdateOnboardingProgressDto for validation
- Endpoints: GET/PUT /api/v1/preferences/onboarding, POST /api/v1/preferences/onboarding/complete

### Families Module - Role-Based Invites
- Add generateRoleInviteCode() - Generate role-specific codes with expiration
- Add getRoleInviteCodes() - Retrieve all active codes for a family
- Add joinFamilyWithRoleCode() - Join family with automatic role assignment
- Add revokeRoleInviteCode() - Revoke specific role invite codes
- Add sendEmailInvite() - Generate code and send email invitation
- Endpoints: POST/GET/DELETE /api/v1/families/:id/invite-codes, POST /api/v1/families/join-with-role, POST /api/v1/families/:id/email-invite

### Email Service
- Add sendFamilyInviteEmail() - Send role-based invitation emails
- Beautiful HTML templates with role badges (👨‍👩‍👧 parent, 🤝 caregiver, 👁️ viewer)
- Role-specific permission descriptions
- Graceful fallback if email sending fails

### Auth Service
- Fix duplicate family creation bug in joinFamily()
- Ensure users only join family once during onboarding

## Frontend Changes

### Onboarding Page
- Reduce steps from 5 to 4 (combined language + measurements)
- Replace card-based selection with dropdown selectors
- Add automatic progress saving after each step
- Add progress restoration on page mount
- Extract FamilySetupStep into reusable component

### Family Page
- Add RoleInvitesSection component with accordion UI
- Generate/view/copy/regenerate/revoke controls for each role
- Send email invites directly from UI
- Display expiration dates (e.g., "Expires in 5 days")
- Info tooltips explaining role permissions
- Only visible to users with parent role

### API Client
- Add role-based invite methods to families API
- Add onboarding progress methods to users API
- TypeScript interfaces for all new data structures

## Features

 Streamlined 4-step onboarding with dropdown selectors
 Automatic progress save/restore across sessions
 Role-based family invites (parent/caregiver/viewer)
 Beautiful email invitations with role descriptions
 Automatic role assignment when joining with invite codes
 Granular permission control per role
 Email fallback if sending fails
 All changes tested and production-ready

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:25:16 +00:00

481 lines
16 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, useEffect } 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';
import { FamilySetupStep, ChildData } from '@/components/onboarding/FamilySetupStep';
import { familiesApi } from '@/lib/api/families';
const steps = ['Welcome', 'Preferences', 'Family Setup', '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();
// Restore onboarding progress on mount
useEffect(() => {
const restoreProgress = async () => {
try {
if (user?.id) {
const status = await usersApi.getOnboardingStatus();
// If onboarding is completed, redirect to home
if (status.completed) {
router.push('/');
return;
}
// If user has saved progress, restore it
if (status.step > 0) {
setActiveStep(status.step);
if (status.data?.selectedLanguage) {
setSelectedLanguage(status.data.selectedLanguage);
}
if (status.data?.selectedMeasurement) {
setSelectedMeasurement(status.data.selectedMeasurement);
}
}
}
} catch (err) {
console.error('Failed to restore onboarding progress:', err);
// Continue with default state if restore fails
}
};
restoreProgress();
}, [user?.id, router]);
const handleNext = async () => {
setError('');
// Step 1: Save preferences (language + measurement)
if (activeStep === 1) {
try {
setLoading(true);
await setLanguage(selectedLanguage);
setMeasurementSystem(selectedMeasurement);
// Save progress to backend
if (user?.id) {
await usersApi.updateOnboardingProgress({
step: activeStep + 1,
data: {
selectedLanguage,
selectedMeasurement,
},
});
await refreshUser();
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} catch (err: any) {
console.error('Failed to save preferences:', err);
// Preferences were saved locally even if backend failed, so continue
setActiveStep((prevActiveStep) => prevActiveStep + 1);
} finally {
setLoading(false);
}
return;
}
// Step 2: Family Setup - This is now handled by FamilySetupStep component
if (activeStep === 2) {
// The component handles the logic internally via onCreateFamily or onJoinFamily
return;
}
if (activeStep === steps.length - 1) {
// Complete onboarding
try {
setLoading(true);
if (user?.id) {
await usersApi.completeOnboarding();
}
router.push('/');
} catch (err) {
console.error('Failed to mark onboarding complete:', err);
// Navigate anyway since onboarding flow is done
router.push('/');
} finally {
setLoading(false);
}
} else {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleSkip = () => {
router.push('/');
};
const handleCreateFamily = async (childData: ChildData) => {
try {
setLoading(true);
setError('');
// Create a new family for the user
const createdFamily = await familiesApi.createFamily({
name: `${user?.name || 'My'}'s Family`,
});
await refreshUser(); // Refresh to get new family membership
// Add the first child to the new family
await childrenApi.createChild(createdFamily.id, {
name: childData.name.trim(),
birthDate: childData.birthDate,
gender: childData.gender,
});
setActiveStep((prev) => prev + 1);
} catch (err: any) {
console.error('Failed to create family:', err);
setError(err.response?.data?.message || 'Failed to create family. Please try again.');
} finally {
setLoading(false);
}
};
const handleJoinFamily = async (shareCode: string) => {
try {
setLoading(true);
setError('');
await familiesApi.joinFamily({ shareCode });
await refreshUser(); // Refresh to get new family membership
setActiveStep((prev) => prev + 1);
} catch (err: any) {
console.error('Failed to join family:', err);
setError(err.response?.data?.message || 'Failed to join family. Please check the code and try again.');
} finally {
setLoading(false);
}
};
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 = 'Family Setup';
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: Preferences (Language + Measurements) */}
{activeStep === 1 && (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="600">
{t('preferences.title') || 'Preferences'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('preferences.subtitle') || 'Set your language and measurement preferences'}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
{/* Language Dropdown */}
<TextField
select
fullWidth
label={t('language.title') || 'Language'}
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)}
margin="normal"
sx={{ mb: 2 }}
>
{supportedLanguages.map((lang) => (
<MenuItem key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</MenuItem>
))}
</TextField>
{/* Measurement Dropdown */}
<TextField
select
fullWidth
label={t('measurements.title') || 'Measurement Units'}
value={selectedMeasurement}
onChange={(e) => setSelectedMeasurement(e.target.value as 'metric' | 'imperial')}
margin="normal"
>
<MenuItem value="metric">
{t('measurements.metric.title') || 'Metric'} (kg, cm, °C)
</MenuItem>
<MenuItem value="imperial">
{t('measurements.imperial.title') || 'Imperial'} (lbs, inches, °F)
</MenuItem>
</TextField>
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
{t('preferences.description') || 'You can change these settings later in your profile.'}
</Alert>
</Box>
)}
{/* Step 2: Family Setup */}
{activeStep === 2 && (
<FamilySetupStep
onCreateFamily={handleCreateFamily}
onJoinFamily={handleJoinFamily}
loading={loading}
error={error}
t={t}
/>
)}
{/* Step 3: Complete */}
{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">
{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>
);
}