Files
maternal-app/maternal-web/components/onboarding/FamilySetupStep.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

252 lines
7.1 KiB
TypeScript

'use client';
import { useState } from 'react';
import {
Box,
Card,
CardActionArea,
CardContent,
Typography,
TextField,
Button,
Alert,
MenuItem,
} from '@mui/material';
import { FamilyRestroom, PersonAdd } from '@mui/icons-material';
export interface ChildData {
name: string;
birthDate: string;
gender: 'male' | 'female' | 'other';
}
interface FamilySetupStepProps {
onCreateFamily: (childData: ChildData) => Promise<void>;
onJoinFamily: (shareCode: string) => Promise<void>;
loading: boolean;
error: string;
t: (key: string) => string;
}
export function FamilySetupStep({
onCreateFamily,
onJoinFamily,
loading,
error,
t,
}: FamilySetupStepProps) {
const [choice, setChoice] = useState<'create' | 'join' | null>(null);
const [shareCode, setShareCode] = useState('');
const [childName, setChildName] = useState('');
const [childBirthDate, setChildBirthDate] = useState('');
const [childGender, setChildGender] = useState<'male' | 'female' | 'other'>('other');
if (choice === 'create') {
return (
<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>
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
<Button onClick={() => setChoice(null)} disabled={loading}>
{t('navigation.back')}
</Button>
<Button
variant="contained"
onClick={() => {
if (!childName.trim() || !childBirthDate) {
return;
}
onCreateFamily({ name: childName, birthDate: childBirthDate, gender: childGender });
}}
disabled={loading || !childName.trim() || !childBirthDate}
fullWidth
>
Create Family
</Button>
</Box>
</Box>
);
}
if (choice === 'join') {
return (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="600">
Join Existing Family
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Enter the family code to join an existing family
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="Family Code"
value={shareCode}
onChange={(e) => setShareCode(e.target.value.toUpperCase())}
margin="normal"
placeholder="ABC123DEF"
helperText="Ask your family admin for the code"
required
disabled={loading}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<Alert severity="info" sx={{ mt: 2, borderRadius: 2 }}>
You'll be added as a viewer by default. The admin can change your role later.
</Alert>
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
<Button onClick={() => setChoice(null)} disabled={loading}>
{t('navigation.back')}
</Button>
<Button
variant="contained"
onClick={() => onJoinFamily(shareCode)}
disabled={loading || !shareCode}
fullWidth
>
Join Family
</Button>
</Box>
</Box>
);
}
return (
<Box sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom align="center" fontWeight="600">
Choose Your Setup
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 4 }}>
Start tracking or join an existing family
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Card
sx={{
transition: 'all 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea onClick={() => setChoice('create')} sx={{ p: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FamilyRestroom fontSize="large" color="primary" />
<Box>
<Typography variant="h6" fontWeight="600">
Create New Family
</Typography>
<Typography variant="body2" color="text.secondary">
Start tracking for your child. Invite family members later.
</Typography>
</Box>
</Box>
</CardContent>
</CardActionArea>
</Card>
<Card
sx={{
transition: 'all 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea onClick={() => setChoice('join')} sx={{ p: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<PersonAdd fontSize="large" color="primary" />
<Box>
<Typography variant="h6" fontWeight="600">
Join Existing Family
</Typography>
<Typography variant="body2" color="text.secondary">
Enter family code to join. Access shared tracking.
</Typography>
</Box>
</Box>
</CardContent>
</CardActionArea>
</Card>
</Box>
</Box>
);
}