1733 lines
44 KiB
Markdown
1733 lines
44 KiB
Markdown
# Web Frontend Implementation Plan - Maternal Organization App
|
||
## Mobile-First Progressive Web Application
|
||
|
||
---
|
||
|
||
## Executive Summary
|
||
|
||
This document outlines the implementation plan for building the Maternal Organization App as a **mobile-first responsive web application** that will serve as the foundation for the native mobile apps. The web app will be built using **Next.js 14** with **TypeScript**, implementing all core features while maintaining a native app-like experience through PWA capabilities.
|
||
|
||
### Key Principles
|
||
- **Mobile-first design** with responsive scaling for tablets and desktop
|
||
- **PWA features** for offline support and app-like experience
|
||
- **Component reusability** for future React Native migration
|
||
- **Touch-optimized** interactions and gestures
|
||
- **Performance-first** with sub-2-second load times
|
||
|
||
---
|
||
|
||
## Technology Stack
|
||
|
||
### Core Framework
|
||
```javascript
|
||
// Package versions for consistency
|
||
{
|
||
"next": "^14.2.0",
|
||
"react": "^18.3.0",
|
||
"react-dom": "^18.3.0",
|
||
"typescript": "^5.4.0"
|
||
}
|
||
```
|
||
|
||
### UI Framework & Styling
|
||
```javascript
|
||
{
|
||
// Material UI for consistent Material Design
|
||
"@mui/material": "^5.15.0",
|
||
"@mui/icons-material": "^5.15.0",
|
||
"@emotion/react": "^11.11.0",
|
||
"@emotion/styled": "^11.11.0",
|
||
|
||
// Tailwind for utility classes
|
||
"tailwindcss": "^3.4.0",
|
||
|
||
// Animation libraries
|
||
"framer-motion": "^11.0.0",
|
||
"react-spring": "^9.7.0"
|
||
}
|
||
```
|
||
|
||
### State Management & Data
|
||
```javascript
|
||
{
|
||
// Redux Toolkit for state management
|
||
"@reduxjs/toolkit": "^2.2.0",
|
||
"react-redux": "^9.1.0",
|
||
"redux-persist": "^6.0.0",
|
||
|
||
// API and real-time
|
||
"@tanstack/react-query": "^5.28.0",
|
||
"socket.io-client": "^4.7.0",
|
||
"axios": "^1.6.0",
|
||
|
||
// Forms and validation
|
||
"react-hook-form": "^7.51.0",
|
||
"zod": "^3.22.0"
|
||
}
|
||
```
|
||
|
||
### PWA & Performance
|
||
```javascript
|
||
{
|
||
"workbox-webpack-plugin": "^7.0.0",
|
||
"next-pwa": "^5.6.0",
|
||
"@sentry/nextjs": "^7.100.0",
|
||
"web-vitals": "^3.5.0"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
maternal-web/
|
||
├── src/
|
||
│ ├── app/ # Next.js 14 app directory
|
||
│ │ ├── (auth)/ # Auth group routes
|
||
│ │ │ ├── login/
|
||
│ │ │ ├── register/
|
||
│ │ │ └── onboarding/
|
||
│ │ ├── (dashboard)/ # Protected routes
|
||
│ │ │ ├── page.tsx # Family dashboard
|
||
│ │ │ ├── children/
|
||
│ │ │ │ ├── [id]/
|
||
│ │ │ │ └── new/
|
||
│ │ │ ├── track/
|
||
│ │ │ │ ├── feeding/
|
||
│ │ │ │ ├── sleep/
|
||
│ │ │ │ └── diaper/
|
||
│ │ │ ├── ai-assistant/
|
||
│ │ │ ├── insights/
|
||
│ │ │ └── settings/
|
||
│ │ ├── api/ # API routes
|
||
│ │ │ └── auth/[...nextauth]/
|
||
│ │ ├── layout.tsx
|
||
│ │ └── global.css
|
||
│ │
|
||
│ ├── components/
|
||
│ │ ├── ui/ # Base UI components
|
||
│ │ │ ├── Button/
|
||
│ │ │ ├── Card/
|
||
│ │ │ ├── Input/
|
||
│ │ │ ├── Modal/
|
||
│ │ │ └── ...
|
||
│ │ ├── features/ # Feature-specific components
|
||
│ │ │ ├── tracking/
|
||
│ │ │ ├── ai-chat/
|
||
│ │ │ ├── family/
|
||
│ │ │ └── analytics/
|
||
│ │ ├── layouts/
|
||
│ │ │ ├── MobileNav/
|
||
│ │ │ ├── TabBar/
|
||
│ │ │ └── AppShell/
|
||
│ │ └── common/
|
||
│ │ ├── ErrorBoundary/
|
||
│ │ ├── LoadingStates/
|
||
│ │ └── OfflineIndicator/
|
||
│ │
|
||
│ ├── hooks/
|
||
│ │ ├── useVoiceInput.ts
|
||
│ │ ├── useOfflineSync.ts
|
||
│ │ ├── useRealtime.ts
|
||
│ │ └── useMediaQuery.ts
|
||
│ │
|
||
│ ├── lib/
|
||
│ │ ├── api/
|
||
│ │ ├── websocket/
|
||
│ │ ├── storage/
|
||
│ │ └── utils/
|
||
│ │
|
||
│ ├── store/ # Redux store
|
||
│ │ ├── slices/
|
||
│ │ ├── middleware/
|
||
│ │ └── store.ts
|
||
│ │
|
||
│ ├── styles/
|
||
│ │ ├── themes/
|
||
│ │ └── globals.css
|
||
│ │
|
||
│ └── types/
|
||
│
|
||
├── public/
|
||
│ ├── manifest.json
|
||
│ ├── service-worker.js
|
||
│ └── icons/
|
||
│
|
||
└── tests/
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1: Foundation & Setup (Week 1)
|
||
|
||
### 1.1 Project Initialization
|
||
|
||
```bash
|
||
# Create Next.js project with TypeScript
|
||
npx create-next-app@latest maternal-web --typescript --tailwind --app
|
||
|
||
# Install core dependencies
|
||
cd maternal-web
|
||
npm install @mui/material @emotion/react @emotion/styled
|
||
npm install @reduxjs/toolkit react-redux redux-persist
|
||
npm install @tanstack/react-query axios socket.io-client
|
||
npm install react-hook-form zod
|
||
npm install framer-motion
|
||
```
|
||
|
||
### 1.2 PWA Configuration
|
||
|
||
```javascript
|
||
// next.config.js
|
||
const withPWA = require('next-pwa')({
|
||
dest: 'public',
|
||
register: true,
|
||
skipWaiting: true,
|
||
disable: process.env.NODE_ENV === 'development',
|
||
runtimeCaching: [
|
||
{
|
||
urlPattern: /^https?.*/,
|
||
handler: 'NetworkFirst',
|
||
options: {
|
||
cacheName: 'offlineCache',
|
||
expiration: {
|
||
maxEntries: 200,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
module.exports = withPWA({
|
||
reactStrictMode: true,
|
||
images: {
|
||
domains: ['api.maternalapp.com'],
|
||
},
|
||
});
|
||
```
|
||
|
||
### 1.3 Theme Configuration
|
||
|
||
```typescript
|
||
// src/styles/themes/maternalTheme.ts
|
||
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',
|
||
},
|
||
},
|
||
typography: {
|
||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||
h1: {
|
||
fontSize: '2rem',
|
||
fontWeight: 600,
|
||
},
|
||
},
|
||
shape: {
|
||
borderRadius: 16,
|
||
},
|
||
components: {
|
||
MuiButton: {
|
||
styleOverrides: {
|
||
root: {
|
||
borderRadius: 24,
|
||
textTransform: 'none',
|
||
minHeight: 48, // Touch target size
|
||
fontSize: '1rem',
|
||
fontWeight: 500,
|
||
},
|
||
},
|
||
},
|
||
MuiTextField: {
|
||
styleOverrides: {
|
||
root: {
|
||
'& .MuiInputBase-root': {
|
||
minHeight: 48,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
### 1.4 Mobile-First Layout Component
|
||
|
||
```typescript
|
||
// src/components/layouts/AppShell/AppShell.tsx
|
||
import { useState, useEffect } from 'react';
|
||
import { Box, Container } from '@mui/material';
|
||
import { MobileNav } from '../MobileNav';
|
||
import { TabBar } from '../TabBar';
|
||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||
|
||
export const AppShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||
const isTablet = useMediaQuery('(max-width: 1024px)');
|
||
|
||
return (
|
||
<Box sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
minHeight: '100vh',
|
||
pb: isMobile ? '56px' : 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>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: Authentication & Onboarding (Week 1-2)
|
||
|
||
### 2.1 Auth Provider Setup
|
||
|
||
```typescript
|
||
// src/lib/auth/AuthContext.tsx
|
||
import { createContext, useContext, useEffect, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { api } from '@/lib/api';
|
||
|
||
interface AuthContextType {
|
||
user: User | null;
|
||
login: (credentials: LoginCredentials) => Promise<void>;
|
||
register: (data: RegisterData) => Promise<void>;
|
||
logout: () => void;
|
||
isLoading: boolean;
|
||
}
|
||
|
||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||
const [user, setUser] = useState<User | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
// Check for stored token and validate
|
||
const checkAuth = async () => {
|
||
const token = localStorage.getItem('accessToken');
|
||
if (token) {
|
||
try {
|
||
const response = await api.get('/auth/me');
|
||
setUser(response.data);
|
||
} catch {
|
||
localStorage.removeItem('accessToken');
|
||
localStorage.removeItem('refreshToken');
|
||
}
|
||
}
|
||
setIsLoading(false);
|
||
};
|
||
|
||
checkAuth();
|
||
}, []);
|
||
|
||
// Implementation continues...
|
||
};
|
||
```
|
||
|
||
### 2.2 Mobile-Optimized Auth Screens
|
||
|
||
```typescript
|
||
// src/app/(auth)/login/page.tsx
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import {
|
||
Box,
|
||
TextField,
|
||
Button,
|
||
Typography,
|
||
Paper,
|
||
InputAdornment,
|
||
IconButton,
|
||
Divider
|
||
} 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';
|
||
|
||
const loginSchema = z.object({
|
||
email: z.string().email('Invalid email'),
|
||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||
});
|
||
|
||
export default function LoginPage() {
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||
resolver: zodResolver(loginSchema),
|
||
});
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
minHeight: '100vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
px: 3,
|
||
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: 400,
|
||
mx: 'auto',
|
||
background: 'rgba(255, 255, 255, 0.95)',
|
||
backdropFilter: 'blur(10px)',
|
||
}}
|
||
>
|
||
<Typography variant="h4" gutterBottom align="center" fontWeight="600">
|
||
Welcome Back
|
||
</Typography>
|
||
|
||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ mt: 3 }}>
|
||
<TextField
|
||
fullWidth
|
||
label="Email"
|
||
type="email"
|
||
margin="normal"
|
||
error={!!errors.email}
|
||
helperText={errors.email?.message}
|
||
{...register('email')}
|
||
InputProps={{
|
||
sx: { borderRadius: 3 }
|
||
}}
|
||
/>
|
||
|
||
<TextField
|
||
fullWidth
|
||
label="Password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
margin="normal"
|
||
error={!!errors.password}
|
||
helperText={errors.password?.message}
|
||
{...register('password')}
|
||
InputProps={{
|
||
sx: { borderRadius: 3 },
|
||
endAdornment: (
|
||
<InputAdornment position="end">
|
||
<IconButton onClick={() => setShowPassword(!showPassword)}>
|
||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||
</IconButton>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
<Button
|
||
fullWidth
|
||
type="submit"
|
||
variant="contained"
|
||
size="large"
|
||
sx={{ mt: 3, mb: 2 }}
|
||
>
|
||
Sign In
|
||
</Button>
|
||
</Box>
|
||
|
||
<Divider sx={{ my: 3 }}>OR</Divider>
|
||
|
||
<Button
|
||
fullWidth
|
||
variant="outlined"
|
||
startIcon={<Google />}
|
||
size="large"
|
||
sx={{ mb: 2 }}
|
||
>
|
||
Continue with Google
|
||
</Button>
|
||
|
||
<Button
|
||
fullWidth
|
||
variant="outlined"
|
||
startIcon={<Apple />}
|
||
size="large"
|
||
>
|
||
Continue with Apple
|
||
</Button>
|
||
</Paper>
|
||
</motion.div>
|
||
</Box>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 2.3 Progressive Onboarding Flow
|
||
|
||
```typescript
|
||
// src/app/(auth)/onboarding/page.tsx
|
||
import { useState } from 'react';
|
||
import { Stepper, Step, StepLabel } from '@mui/material';
|
||
import { WelcomeStep } from '@/components/onboarding/WelcomeStep';
|
||
import { AddChildStep } from '@/components/onboarding/AddChildStep';
|
||
import { InviteFamilyStep } from '@/components/onboarding/InviteFamilyStep';
|
||
import { NotificationStep } from '@/components/onboarding/NotificationStep';
|
||
|
||
const steps = ['Welcome', 'Add Child', 'Invite Family', 'Notifications'];
|
||
|
||
export default function OnboardingPage() {
|
||
const [activeStep, setActiveStep] = useState(0);
|
||
|
||
return (
|
||
<Box sx={{ width: '100%', p: 3 }}>
|
||
{/* Mobile-optimized stepper */}
|
||
<Stepper activeStep={activeStep} alternativeLabel>
|
||
{steps.map((label) => (
|
||
<Step key={label}>
|
||
<StepLabel>{label}</StepLabel>
|
||
</Step>
|
||
))}
|
||
</Stepper>
|
||
|
||
<Box sx={{ mt: 4 }}>
|
||
{activeStep === 0 && <WelcomeStep onNext={handleNext} />}
|
||
{activeStep === 1 && <AddChildStep onNext={handleNext} onBack={handleBack} />}
|
||
{activeStep === 2 && <InviteFamilyStep onNext={handleNext} onBack={handleBack} onSkip={handleSkip} />}
|
||
{activeStep === 3 && <NotificationStep onComplete={handleComplete} onBack={handleBack} />}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3: Core Tracking Features (Week 2-3)
|
||
|
||
### 3.1 Quick Action FAB
|
||
|
||
```typescript
|
||
// src/components/features/tracking/QuickActionFAB.tsx
|
||
import { useState } from 'react';
|
||
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material';
|
||
import {
|
||
Restaurant,
|
||
Hotel,
|
||
BabyChangingStation,
|
||
Mic,
|
||
Add
|
||
} from '@mui/icons-material';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
||
export const QuickActionFAB = () => {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const actions = [
|
||
{ icon: <Restaurant />, name: 'Feeding', color: '#FFB6C1', route: '/track/feeding' },
|
||
{ icon: <Hotel />, name: 'Sleep', color: '#B6D7FF', route: '/track/sleep' },
|
||
{ icon: <BabyChangingStation />, name: 'Diaper', color: '#FFE4B5', route: '/track/diaper' },
|
||
{ icon: <Mic />, name: 'Voice', color: '#E6E6FA', action: 'voice' },
|
||
];
|
||
|
||
return (
|
||
<SpeedDial
|
||
ariaLabel="Quick actions"
|
||
sx={{
|
||
position: 'fixed',
|
||
bottom: 72, // Above tab bar
|
||
right: 16,
|
||
'& .MuiSpeedDial-fab': {
|
||
bgcolor: 'secondary.main',
|
||
width: 64,
|
||
height: 64,
|
||
}
|
||
}}
|
||
icon={<SpeedDialIcon icon={<Add />} />}
|
||
onClose={() => setOpen(false)}
|
||
onOpen={() => setOpen(true)}
|
||
open={open}
|
||
>
|
||
{actions.map((action) => (
|
||
<SpeedDialAction
|
||
key={action.name}
|
||
icon={action.icon}
|
||
tooltipTitle={action.name}
|
||
onClick={() => handleAction(action)}
|
||
sx={{
|
||
bgcolor: action.color,
|
||
'&:hover': {
|
||
bgcolor: action.color,
|
||
transform: 'scale(1.1)',
|
||
}
|
||
}}
|
||
/>
|
||
))}
|
||
</SpeedDial>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 3.2 Voice Input Hook
|
||
|
||
```typescript
|
||
// src/hooks/useVoiceInput.ts
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
|
||
interface VoiceInputOptions {
|
||
language?: string;
|
||
continuous?: boolean;
|
||
onResult?: (transcript: string) => void;
|
||
onEnd?: () => void;
|
||
}
|
||
|
||
export const useVoiceInput = (options: VoiceInputOptions = {}) => {
|
||
const [isListening, setIsListening] = useState(false);
|
||
const [transcript, setTranscript] = useState('');
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
const recognition = new SpeechRecognition();
|
||
|
||
useEffect(() => {
|
||
recognition.continuous = options.continuous ?? false;
|
||
recognition.interimResults = true;
|
||
recognition.lang = options.language ?? 'en-US';
|
||
|
||
recognition.onresult = (event) => {
|
||
const current = event.resultIndex;
|
||
const transcript = event.results[current][0].transcript;
|
||
setTranscript(transcript);
|
||
|
||
if (event.results[current].isFinal) {
|
||
options.onResult?.(transcript);
|
||
}
|
||
};
|
||
|
||
recognition.onerror = (event) => {
|
||
setError(event.error);
|
||
setIsListening(false);
|
||
};
|
||
|
||
recognition.onend = () => {
|
||
setIsListening(false);
|
||
options.onEnd?.();
|
||
};
|
||
}, []);
|
||
|
||
const startListening = useCallback(() => {
|
||
setTranscript('');
|
||
setError(null);
|
||
recognition.start();
|
||
setIsListening(true);
|
||
}, [recognition]);
|
||
|
||
const stopListening = useCallback(() => {
|
||
recognition.stop();
|
||
setIsListening(false);
|
||
}, [recognition]);
|
||
|
||
return {
|
||
isListening,
|
||
transcript,
|
||
error,
|
||
startListening,
|
||
stopListening,
|
||
};
|
||
};
|
||
```
|
||
|
||
### 3.3 Tracking Form Component
|
||
|
||
```typescript
|
||
// src/components/features/tracking/FeedingTracker.tsx
|
||
import { useState } from 'react';
|
||
import {
|
||
Box,
|
||
Paper,
|
||
TextField,
|
||
ToggleButtonGroup,
|
||
ToggleButton,
|
||
Button,
|
||
Typography,
|
||
Chip,
|
||
IconButton
|
||
} from '@mui/material';
|
||
import { Timer, Mic, MicOff } from '@mui/icons-material';
|
||
import { useVoiceInput } from '@/hooks/useVoiceInput';
|
||
import { motion } from 'framer-motion';
|
||
|
||
export const FeedingTracker = () => {
|
||
const [feedingType, setFeedingType] = useState<'breast' | 'bottle' | 'solid'>('breast');
|
||
const [duration, setDuration] = useState(0);
|
||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||
|
||
const { isListening, transcript, startListening, stopListening } = useVoiceInput({
|
||
onResult: (text) => {
|
||
// Parse voice input for commands
|
||
parseVoiceCommand(text);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
p: 3,
|
||
borderRadius: 4,
|
||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFF 100%)',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||
<Typography variant="h5" fontWeight="600">
|
||
Track Feeding
|
||
</Typography>
|
||
|
||
<IconButton
|
||
color={isListening ? 'primary' : 'default'}
|
||
onClick={isListening ? stopListening : startListening}
|
||
sx={{
|
||
bgcolor: isListening ? 'primary.light' : 'background.paper',
|
||
animation: isListening ? 'pulse 1.5s infinite' : 'none',
|
||
}}
|
||
>
|
||
{isListening ? <Mic /> : <MicOff />}
|
||
</IconButton>
|
||
</Box>
|
||
|
||
<ToggleButtonGroup
|
||
value={feedingType}
|
||
exclusive
|
||
onChange={(e, value) => value && setFeedingType(value)}
|
||
fullWidth
|
||
sx={{ mb: 3 }}
|
||
>
|
||
<ToggleButton value="breast" sx={{ borderRadius: 3 }}>
|
||
Breastfeeding
|
||
</ToggleButton>
|
||
<ToggleButton value="bottle" sx={{ borderRadius: 3 }}>
|
||
Bottle
|
||
</ToggleButton>
|
||
<ToggleButton value="solid" sx={{ borderRadius: 3 }}>
|
||
Solid Food
|
||
</ToggleButton>
|
||
</ToggleButtonGroup>
|
||
|
||
{/* Timer component for breastfeeding */}
|
||
{feedingType === 'breast' && (
|
||
<Box sx={{ textAlign: 'center', my: 4 }}>
|
||
<Typography variant="h2" fontWeight="300">
|
||
{formatDuration(duration)}
|
||
</Typography>
|
||
<Button
|
||
variant="contained"
|
||
size="large"
|
||
startIcon={<Timer />}
|
||
onClick={toggleTimer}
|
||
sx={{ mt: 2 }}
|
||
>
|
||
{isTimerRunning ? 'Stop Timer' : 'Start Timer'}
|
||
</Button>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Form fields for bottle/solid */}
|
||
{feedingType !== 'breast' && (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
{/* Additional form fields */}
|
||
</Box>
|
||
)}
|
||
|
||
<Button
|
||
fullWidth
|
||
variant="contained"
|
||
size="large"
|
||
sx={{ mt: 3 }}
|
||
onClick={handleSave}
|
||
>
|
||
Save Feeding
|
||
</Button>
|
||
</Paper>
|
||
</motion.div>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4: AI Assistant Integration (Week 3-4)
|
||
|
||
### 4.1 AI Chat Interface
|
||
|
||
```typescript
|
||
// src/components/features/ai-chat/AIChatInterface.tsx
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import {
|
||
Box,
|
||
TextField,
|
||
IconButton,
|
||
Paper,
|
||
Typography,
|
||
Avatar,
|
||
Chip,
|
||
CircularProgress
|
||
} from '@mui/material';
|
||
import { Send, Mic, AttachFile } from '@mui/icons-material';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { useAIChat } from '@/hooks/useAIChat';
|
||
|
||
export const AIChatInterface = () => {
|
||
const [input, setInput] = useState('');
|
||
const [isTyping, setIsTyping] = useState(false);
|
||
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
||
|
||
const { messages, sendMessage, isLoading } = useAIChat();
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
return (
|
||
<Box sx={{ height: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column' }}>
|
||
{/* Quick action chips */}
|
||
<Box sx={{ p: 2, display: 'flex', gap: 1, overflowX: 'auto' }}>
|
||
{['Sleep tips', 'Feeding schedule', 'Developmental milestones', 'Emergency'].map((action) => (
|
||
<Chip
|
||
key={action}
|
||
label={action}
|
||
onClick={() => handleQuickAction(action)}
|
||
sx={{
|
||
bgcolor: 'background.paper',
|
||
'&:hover': { bgcolor: 'primary.light' }
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
|
||
{/* Messages */}
|
||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||
<AnimatePresence>
|
||
{messages.map((message, index) => (
|
||
<motion.div
|
||
key={index}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||
mb: 2,
|
||
}}
|
||
>
|
||
{message.role === 'assistant' && (
|
||
<Avatar sx={{ bgcolor: 'primary.main', mr: 1 }}>AI</Avatar>
|
||
)}
|
||
|
||
<Paper
|
||
sx={{
|
||
p: 2,
|
||
maxWidth: '70%',
|
||
bgcolor: message.role === 'user' ? 'primary.light' : 'background.paper',
|
||
borderRadius: message.role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||
}}
|
||
>
|
||
<Typography variant="body1">{message.content}</Typography>
|
||
{message.disclaimer && (
|
||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||
⚠️ {message.disclaimer}
|
||
</Typography>
|
||
)}
|
||
</Paper>
|
||
|
||
{message.role === 'user' && (
|
||
<Avatar sx={{ bgcolor: 'secondary.main', ml: 1 }}>U</Avatar>
|
||
)}
|
||
</Box>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
|
||
{isTyping && (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Avatar sx={{ bgcolor: 'primary.main' }}>AI</Avatar>
|
||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||
<CircularProgress size={8} />
|
||
<CircularProgress size={8} />
|
||
<CircularProgress size={8} />
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</Box>
|
||
|
||
{/* Input area */}
|
||
<Paper
|
||
elevation={3}
|
||
sx={{
|
||
p: 2,
|
||
borderRadius: 0,
|
||
borderTop: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||
<IconButton>
|
||
<AttachFile />
|
||
</IconButton>
|
||
|
||
<TextField
|
||
fullWidth
|
||
multiline
|
||
maxRows={4}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="Ask about your child's development, sleep, feeding..."
|
||
variant="outlined"
|
||
sx={{
|
||
'& .MuiOutlinedInput-root': {
|
||
borderRadius: 3,
|
||
},
|
||
}}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<IconButton>
|
||
<Mic />
|
||
</IconButton>
|
||
|
||
<IconButton
|
||
color="primary"
|
||
disabled={!input.trim() || isLoading}
|
||
onClick={handleSend}
|
||
>
|
||
<Send />
|
||
</IconButton>
|
||
</Box>
|
||
</Paper>
|
||
</Box>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 4.2 AI Context Provider
|
||
|
||
```typescript
|
||
// src/lib/ai/AIContextProvider.tsx
|
||
import { createContext, useContext, useState } from 'react';
|
||
import { api } from '@/lib/api';
|
||
|
||
interface AIContextType {
|
||
childContext: ChildContext[];
|
||
recentActivities: Activity[];
|
||
updateContext: (data: Partial<AIContextType>) => void;
|
||
getRelevantContext: (query: string) => Promise<any>;
|
||
}
|
||
|
||
export const AIContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||
const [childContext, setChildContext] = useState<ChildContext[]>([]);
|
||
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
|
||
|
||
const getRelevantContext = async (query: string) => {
|
||
// Implement context prioritization based on query
|
||
const context = {
|
||
children: childContext,
|
||
recentActivities: recentActivities.slice(0, 10),
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
|
||
return context;
|
||
};
|
||
|
||
return (
|
||
<AIContext.Provider value={{
|
||
childContext,
|
||
recentActivities,
|
||
updateContext,
|
||
getRelevantContext,
|
||
}}>
|
||
{children}
|
||
</AIContext.Provider>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5: Family Dashboard & Analytics (Week 4-5)
|
||
|
||
### 5.1 Responsive Dashboard Grid
|
||
|
||
```typescript
|
||
// src/app/(dashboard)/page.tsx
|
||
import { Grid, Box } from '@mui/material';
|
||
import { ChildCard } from '@/components/features/family/ChildCard';
|
||
import { ActivityTimeline } from '@/components/features/tracking/ActivityTimeline';
|
||
import { SleepPrediction } from '@/components/features/analytics/SleepPrediction';
|
||
import { QuickStats } from '@/components/features/analytics/QuickStats';
|
||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||
|
||
export default function DashboardPage() {
|
||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||
const { children, activities } = useDashboardData();
|
||
|
||
return (
|
||
<Box sx={{ pb: 8 }}>
|
||
{/* Child selector carousel for mobile */}
|
||
{isMobile && (
|
||
<Box sx={{ mb: 3, overflow: 'auto', display: 'flex', gap: 2, pb: 1 }}>
|
||
{children.map((child) => (
|
||
<ChildCard key={child.id} child={child} compact />
|
||
))}
|
||
</Box>
|
||
)}
|
||
|
||
<Grid container spacing={3}>
|
||
{/* Desktop child cards */}
|
||
{!isMobile && children.map((child) => (
|
||
<Grid item xs={12} md={6} lg={4} key={child.id}>
|
||
<ChildCard child={child} />
|
||
</Grid>
|
||
))}
|
||
|
||
{/* Sleep prediction */}
|
||
<Grid item xs={12} md={6}>
|
||
<SleepPrediction />
|
||
</Grid>
|
||
|
||
{/* Quick stats */}
|
||
<Grid item xs={12} md={6}>
|
||
<QuickStats />
|
||
</Grid>
|
||
|
||
{/* Activity timeline */}
|
||
<Grid item xs={12}>
|
||
<ActivityTimeline activities={activities} />
|
||
</Grid>
|
||
</Grid>
|
||
</Box>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5.2 Interactive Charts
|
||
|
||
```typescript
|
||
// src/components/features/analytics/SleepChart.tsx
|
||
import { Line } from 'react-chartjs-2';
|
||
import { Box, Paper, Typography, ToggleButtonGroup, ToggleButton } from '@mui/material';
|
||
import { useState } from 'react';
|
||
|
||
export const SleepChart = () => {
|
||
const [timeRange, setTimeRange] = useState<'week' | 'month'>('week');
|
||
|
||
const chartData = {
|
||
labels: generateLabels(timeRange),
|
||
datasets: [
|
||
{
|
||
label: 'Sleep Duration',
|
||
data: sleepData,
|
||
fill: true,
|
||
backgroundColor: 'rgba(182, 215, 255, 0.2)',
|
||
borderColor: '#B6D7FF',
|
||
tension: 0.4,
|
||
},
|
||
{
|
||
label: 'Predicted',
|
||
data: predictedData,
|
||
borderColor: '#FFB6C1',
|
||
borderDash: [5, 5],
|
||
fill: false,
|
||
},
|
||
],
|
||
};
|
||
|
||
const options = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false,
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
return (
|
||
<Paper sx={{ p: 3, height: '100%' }}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||
<Typography variant="h6" fontWeight="600">
|
||
Sleep Patterns
|
||
</Typography>
|
||
|
||
<ToggleButtonGroup
|
||
value={timeRange}
|
||
exclusive
|
||
onChange={(e, value) => value && setTimeRange(value)}
|
||
size="small"
|
||
>
|
||
<ToggleButton value="week">Week</ToggleButton>
|
||
<ToggleButton value="month">Month</ToggleButton>
|
||
</ToggleButtonGroup>
|
||
</Box>
|
||
|
||
<Box sx={{ height: 300 }}>
|
||
<Line data={chartData} options={options} />
|
||
</Box>
|
||
</Paper>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6: Offline Support & PWA Features (Week 5)
|
||
|
||
### 6.1 Service Worker Setup
|
||
|
||
```javascript
|
||
// public/service-worker.js
|
||
import { precacheAndRoute } from 'workbox-precaching';
|
||
import { registerRoute } from 'workbox-routing';
|
||
import { NetworkFirst, StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
|
||
import { Queue } from 'workbox-background-sync';
|
||
|
||
// Precache all static assets
|
||
precacheAndRoute(self.__WB_MANIFEST);
|
||
|
||
// Create sync queue for offline requests
|
||
const queue = new Queue('maternal-sync-queue', {
|
||
onSync: async ({ queue }) => {
|
||
let entry;
|
||
while ((entry = await queue.shiftRequest())) {
|
||
try {
|
||
await fetch(entry.request);
|
||
} catch (error) {
|
||
await queue.unshiftRequest(entry);
|
||
throw error;
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
// API routes - network first with offline queue
|
||
registerRoute(
|
||
/^https:\/\/api\.maternalapp\.com\/api/,
|
||
async (args) => {
|
||
try {
|
||
const response = await new NetworkFirst({
|
||
cacheName: 'api-cache',
|
||
networkTimeoutSeconds: 5,
|
||
}).handle(args);
|
||
return response;
|
||
} catch (error) {
|
||
await queue.pushRequest({ request: args.request });
|
||
return new Response(
|
||
JSON.stringify({
|
||
offline: true,
|
||
message: 'Your request has been queued and will be synced when online'
|
||
}),
|
||
{ headers: { 'Content-Type': 'application/json' } }
|
||
);
|
||
}
|
||
}
|
||
);
|
||
|
||
// Static assets - cache first
|
||
registerRoute(
|
||
/\.(?:png|jpg|jpeg|svg|gif|webp)$/,
|
||
new CacheFirst({
|
||
cacheName: 'image-cache',
|
||
plugins: [
|
||
new ExpirationPlugin({
|
||
maxEntries: 100,
|
||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||
}),
|
||
],
|
||
})
|
||
);
|
||
```
|
||
|
||
### 6.2 Offline State Management
|
||
|
||
```typescript
|
||
// src/store/slices/offlineSlice.ts
|
||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||
import { REHYDRATE } from 'redux-persist';
|
||
|
||
interface OfflineState {
|
||
isOnline: boolean;
|
||
pendingActions: PendingAction[];
|
||
lastSyncTime: string | null;
|
||
syncInProgress: boolean;
|
||
}
|
||
|
||
const offlineSlice = createSlice({
|
||
name: 'offline',
|
||
initialState: {
|
||
isOnline: true,
|
||
pendingActions: [],
|
||
lastSyncTime: null,
|
||
syncInProgress: false,
|
||
} as OfflineState,
|
||
reducers: {
|
||
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
|
||
state.isOnline = action.payload;
|
||
if (action.payload && state.pendingActions.length > 0) {
|
||
state.syncInProgress = true;
|
||
}
|
||
},
|
||
addPendingAction: (state, action: PayloadAction<PendingAction>) => {
|
||
state.pendingActions.push({
|
||
...action.payload,
|
||
id: generateId(),
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
},
|
||
removePendingAction: (state, action: PayloadAction<string>) => {
|
||
state.pendingActions = state.pendingActions.filter(a => a.id !== action.payload);
|
||
},
|
||
setSyncInProgress: (state, action: PayloadAction<boolean>) => {
|
||
state.syncInProgress = action.payload;
|
||
},
|
||
updateLastSyncTime: (state) => {
|
||
state.lastSyncTime = new Date().toISOString();
|
||
},
|
||
},
|
||
extraReducers: (builder) => {
|
||
builder.addCase(REHYDRATE, (state, action) => {
|
||
// Handle rehydration from localStorage
|
||
if (action.payload) {
|
||
return {
|
||
...state,
|
||
...action.payload.offline,
|
||
isOnline: navigator.onLine,
|
||
};
|
||
}
|
||
});
|
||
},
|
||
});
|
||
|
||
export const offlineActions = offlineSlice.actions;
|
||
export default offlineSlice.reducer;
|
||
```
|
||
|
||
### 6.3 Offline Indicator Component
|
||
|
||
```typescript
|
||
// src/components/common/OfflineIndicator.tsx
|
||
import { Alert, Snackbar, LinearProgress } from '@mui/material';
|
||
import { useSelector } from 'react-redux';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
||
export const OfflineIndicator = () => {
|
||
const { isOnline, pendingActions, syncInProgress } = useSelector(state => state.offline);
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{!isOnline && (
|
||
<motion.div
|
||
initial={{ y: -100, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
exit={{ y: -100, opacity: 0 }}
|
||
>
|
||
<Alert
|
||
severity="warning"
|
||
sx={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
borderRadius: 0,
|
||
zIndex: 9999,
|
||
}}
|
||
>
|
||
You're offline. {pendingActions.length} actions will sync when you're back online.
|
||
</Alert>
|
||
</motion.div>
|
||
)}
|
||
|
||
{syncInProgress && (
|
||
<LinearProgress
|
||
sx={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
zIndex: 9998,
|
||
}}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 7: Performance Optimization (Week 6)
|
||
|
||
### 7.1 Code Splitting & Lazy Loading
|
||
|
||
```typescript
|
||
// src/app/(dashboard)/layout.tsx
|
||
import { lazy, Suspense } from 'react';
|
||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||
|
||
// Lazy load heavy components
|
||
const AIChatInterface = lazy(() => import('@/components/features/ai-chat/AIChatInterface'));
|
||
const AnalyticsDashboard = lazy(() => import('@/components/features/analytics/AnalyticsDashboard'));
|
||
|
||
export default function DashboardLayout({ children }) {
|
||
return (
|
||
<Suspense fallback={<LoadingFallback />}>
|
||
{children}
|
||
</Suspense>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 7.2 Image Optimization
|
||
|
||
```typescript
|
||
// src/components/common/OptimizedImage.tsx
|
||
import Image from 'next/image';
|
||
import { useState } from 'react';
|
||
import { Skeleton } from '@mui/material';
|
||
|
||
export const OptimizedImage = ({ src, alt, ...props }) => {
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
return (
|
||
<Box position="relative">
|
||
{isLoading && (
|
||
<Skeleton
|
||
variant="rectangular"
|
||
width={props.width}
|
||
height={props.height}
|
||
sx={{ position: 'absolute', top: 0, left: 0 }}
|
||
/>
|
||
)}
|
||
<Image
|
||
src={src}
|
||
alt={alt}
|
||
{...props}
|
||
onLoadingComplete={() => setIsLoading(false)}
|
||
placeholder="blur"
|
||
blurDataURL={generateBlurDataURL()}
|
||
/>
|
||
</Box>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 7.3 Performance Monitoring
|
||
|
||
```typescript
|
||
// src/lib/performance/monitoring.ts
|
||
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
|
||
|
||
export const initPerformanceMonitoring = () => {
|
||
// Send metrics to analytics
|
||
const sendToAnalytics = (metric: any) => {
|
||
// Send to your analytics service
|
||
if (window.gtag) {
|
||
window.gtag('event', metric.name, {
|
||
value: Math.round(metric.value),
|
||
metric_id: metric.id,
|
||
metric_value: metric.value,
|
||
metric_delta: metric.delta,
|
||
});
|
||
}
|
||
};
|
||
|
||
getCLS(sendToAnalytics);
|
||
getFID(sendToAnalytics);
|
||
getFCP(sendToAnalytics);
|
||
getLCP(sendToAnalytics);
|
||
getTTFB(sendToAnalytics);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 8: Testing & Deployment (Week 6-7)
|
||
|
||
### 8.1 Testing Setup
|
||
|
||
```typescript
|
||
// jest.config.js
|
||
module.exports = {
|
||
preset: 'ts-jest',
|
||
testEnvironment: 'jsdom',
|
||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||
moduleNameMapper: {
|
||
'^@/(.*)$': '<rootDir>/src/$1',
|
||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||
},
|
||
collectCoverageFrom: [
|
||
'src/**/*.{ts,tsx}',
|
||
'!src/**/*.d.ts',
|
||
'!src/**/*.stories.tsx',
|
||
],
|
||
coverageThreshold: {
|
||
global: {
|
||
branches: 80,
|
||
functions: 80,
|
||
lines: 80,
|
||
statements: 80,
|
||
},
|
||
},
|
||
};
|
||
```
|
||
|
||
### 8.2 E2E Testing with Playwright
|
||
|
||
```typescript
|
||
// tests/e2e/tracking.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('Activity Tracking Flow', () => {
|
||
test('should track feeding activity', async ({ page }) => {
|
||
// Login
|
||
await page.goto('/login');
|
||
await page.fill('[name="email"]', 'test@example.com');
|
||
await page.fill('[name="password"]', 'password123');
|
||
await page.click('button[type="submit"]');
|
||
|
||
// Navigate to tracking
|
||
await page.waitForSelector('[data-testid="quick-action-fab"]');
|
||
await page.click('[data-testid="quick-action-fab"]');
|
||
await page.click('[data-testid="action-feeding"]');
|
||
|
||
// Fill form
|
||
await page.click('[value="bottle"]');
|
||
await page.fill('[name="amount"]', '120');
|
||
await page.click('[data-testid="save-feeding"]');
|
||
|
||
// Verify
|
||
await expect(page.locator('[data-testid="success-toast"]')).toBeVisible();
|
||
});
|
||
});
|
||
```
|
||
|
||
### 8.3 Deployment Configuration
|
||
|
||
```yaml
|
||
# .github/workflows/deploy.yml
|
||
name: Deploy to Production
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-node@v3
|
||
with:
|
||
node-version: '18'
|
||
- run: npm ci
|
||
- run: npm test
|
||
- run: npm run test:e2e
|
||
|
||
build-and-deploy:
|
||
needs: test
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-node@v3
|
||
- run: npm ci
|
||
- run: npm run build
|
||
|
||
# Deploy to Vercel
|
||
- uses: amondnet/vercel-action@v20
|
||
with:
|
||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||
vercel-org-id: ${{ secrets.ORG_ID }}
|
||
vercel-project-id: ${{ secrets.PROJECT_ID }}
|
||
working-directory: ./
|
||
```
|
||
|
||
---
|
||
|
||
## Migration Path to Mobile
|
||
|
||
### Component Compatibility Strategy
|
||
|
||
```typescript
|
||
// Shared component interface example
|
||
// src/components/shared/Button.tsx
|
||
|
||
interface ButtonProps {
|
||
variant?: 'contained' | 'outlined' | 'text';
|
||
color?: 'primary' | 'secondary';
|
||
size?: 'small' | 'medium' | 'large';
|
||
onPress?: () => void; // Mobile
|
||
onClick?: () => void; // Web
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
// Web implementation
|
||
export const Button: React.FC<ButtonProps> = ({
|
||
onClick,
|
||
onPress,
|
||
children,
|
||
...props
|
||
}) => {
|
||
return (
|
||
<MuiButton onClick={onClick || onPress} {...props}>
|
||
{children}
|
||
</MuiButton>
|
||
);
|
||
};
|
||
|
||
// Future React Native implementation
|
||
// export const Button: React.FC<ButtonProps> = ({
|
||
// onClick,
|
||
// onPress,
|
||
// children,
|
||
// ...props
|
||
// }) => {
|
||
// return (
|
||
// <TouchableOpacity onPress={onPress || onClick}>
|
||
// <Text>{children}</Text>
|
||
// </TouchableOpacity>
|
||
// );
|
||
// };
|
||
```
|
||
|
||
### Shared Business Logic
|
||
|
||
```typescript
|
||
// src/lib/shared/tracking.logic.ts
|
||
// This file can be shared between web and mobile
|
||
|
||
export const calculateFeedingDuration = (start: Date, end: Date): number => {
|
||
return Math.round((end.getTime() - start.getTime()) / 60000);
|
||
};
|
||
|
||
export const validateFeedingData = (data: FeedingData): ValidationResult => {
|
||
const errors = [];
|
||
|
||
if (!data.type) {
|
||
errors.push('Feeding type is required');
|
||
}
|
||
|
||
if (data.type === 'bottle' && !data.amount) {
|
||
errors.push('Amount is required for bottle feeding');
|
||
}
|
||
|
||
return {
|
||
isValid: errors.length === 0,
|
||
errors,
|
||
};
|
||
};
|
||
|
||
export const formatActivityForDisplay = (activity: Activity): DisplayActivity => {
|
||
// Shared formatting logic
|
||
return {
|
||
...activity,
|
||
displayTime: formatRelativeTime(activity.timestamp),
|
||
icon: getActivityIcon(activity.type),
|
||
color: getActivityColor(activity.type),
|
||
};
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Performance Targets
|
||
|
||
### Web Vitals Goals
|
||
- **LCP** (Largest Contentful Paint): < 2.5s
|
||
- **FID** (First Input Delay): < 100ms
|
||
- **CLS** (Cumulative Layout Shift): < 0.1
|
||
- **TTI** (Time to Interactive): < 3.5s
|
||
- **Bundle Size**: < 200KB initial JS
|
||
|
||
### Mobile Experience Metrics
|
||
- Touch target size: minimum 44x44px
|
||
- Font size: minimum 16px for body text
|
||
- Scroll performance: 60fps
|
||
- Offline capability: Full CRUD operations
|
||
- Data usage: < 1MB per session average
|
||
|
||
---
|
||
|
||
## Security Considerations
|
||
|
||
### Authentication & Authorization
|
||
```typescript
|
||
// src/lib/auth/security.ts
|
||
export const securityHeaders = {
|
||
'Content-Security-Policy': `
|
||
default-src 'self';
|
||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||
style-src 'self' 'unsafe-inline';
|
||
img-src 'self' data: https:;
|
||
connect-src 'self' https://api.maternalapp.com wss://api.maternalapp.com;
|
||
`,
|
||
'X-Frame-Options': 'DENY',
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||
'Permissions-Policy': 'camera=(), microphone=(self), geolocation=()',
|
||
};
|
||
```
|
||
|
||
### Data Protection
|
||
- Implement field-level encryption for sensitive data
|
||
- Use secure HttpOnly cookies for auth tokens
|
||
- Sanitize all user inputs
|
||
- Implement rate limiting on all API calls
|
||
- Regular security audits with OWASP guidelines
|
||
|
||
---
|
||
|
||
## Monitoring & Analytics
|
||
|
||
### Analytics Implementation
|
||
```typescript
|
||
// src/lib/analytics/index.ts
|
||
export const trackEvent = (eventName: string, properties?: any) => {
|
||
// PostHog
|
||
if (window.posthog) {
|
||
window.posthog.capture(eventName, properties);
|
||
}
|
||
|
||
// Google Analytics
|
||
if (window.gtag) {
|
||
window.gtag('event', eventName, properties);
|
||
}
|
||
};
|
||
|
||
// Usage
|
||
trackEvent('feeding_tracked', {
|
||
type: 'bottle',
|
||
amount: 120,
|
||
duration: 15,
|
||
});
|
||
```
|
||
|
||
### Error Tracking
|
||
```typescript
|
||
// src/lib/monitoring/sentry.ts
|
||
import * as Sentry from '@sentry/nextjs';
|
||
|
||
Sentry.init({
|
||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||
environment: process.env.NODE_ENV,
|
||
tracesSampleRate: 0.1,
|
||
beforeSend(event, hint) {
|
||
// Filter sensitive data
|
||
if (event.request) {
|
||
delete event.request.cookies;
|
||
}
|
||
return event;
|
||
},
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Launch Checklist
|
||
|
||
### Pre-Launch
|
||
- [ ] Cross-browser testing (Chrome, Safari, Firefox, Edge)
|
||
- [ ] Mobile device testing (iOS Safari, Chrome Android)
|
||
- [ ] Accessibility audit (WCAG AA compliance)
|
||
- [ ] Performance audit (Lighthouse score > 90)
|
||
- [ ] Security audit
|
||
- [ ] SEO optimization
|
||
- [ ] Analytics implementation verified
|
||
- [ ] Error tracking configured
|
||
- [ ] SSL certificate installed
|
||
- [ ] CDN configured
|
||
- [ ] Backup system tested
|
||
|
||
### Post-Launch
|
||
- [ ] Monitor error rates
|
||
- [ ] Track Core Web Vitals
|
||
- [ ] Gather user feedback
|
||
- [ ] A/B testing setup
|
||
- [ ] Performance monitoring dashboard
|
||
- [ ] User behavior analytics
|
||
- [ ] Conversion funnel tracking
|
||
- [ ] Support system ready
|
||
|
||
---
|
||
|
||
## Conclusion
|
||
|
||
This web-first approach provides a solid foundation for the Maternal Organization App while maintaining the flexibility to expand to native mobile platforms. The progressive web app capabilities ensure users get a native-like experience on mobile devices, while the component architecture and shared business logic will facilitate the eventual migration to React Native.
|
||
|
||
Key advantages of this approach:
|
||
1. **Faster time to market** - Single codebase to maintain initially
|
||
2. **Broader reach** - Works on any device with a modern browser
|
||
3. **Easy updates** - No app store approval process for web updates
|
||
4. **Cost-effective** - Reduced development and maintenance costs
|
||
5. **SEO benefits** - Web app can be indexed by search engines
|
||
6. **Progressive enhancement** - Can add native features gradually
|
||
|
||
The architecture is designed to support the future mobile app development by keeping business logic separate and using compatible patterns wherever possible. |