Add comprehensive .gitignore
This commit is contained in:
182
maternal-web/components/common/LoadingFallback.tsx
Normal file
182
maternal-web/components/common/LoadingFallback.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Box, Skeleton, Paper, Container } from '@mui/material';
|
||||
|
||||
interface LoadingFallbackProps {
|
||||
variant?: 'page' | 'card' | 'list' | 'chart' | 'chat';
|
||||
}
|
||||
|
||||
export const LoadingFallback: React.FC<LoadingFallbackProps> = ({ variant = 'page' }) => {
|
||||
if (variant === 'chat') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 200px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header Skeleton */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 0,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width={200} height={28} />
|
||||
<Skeleton variant="text" width={300} height={20} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Messages Skeleton */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Suggested questions */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<Skeleton variant="rounded" width={180} height={32} sx={{ borderRadius: 3 }} />
|
||||
<Skeleton variant="rounded" width={200} height={32} sx={{ borderRadius: 3 }} />
|
||||
<Skeleton variant="rounded" width={160} height={32} sx={{ borderRadius: 3 }} />
|
||||
<Skeleton variant="rounded" width={190} height={32} sx={{ borderRadius: 3 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Input Skeleton */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 0,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||
<Skeleton variant="rounded" sx={{ flex: 1, height: 48, borderRadius: 3 }} />
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'chart') {
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Skeleton variant="text" width={180} height={32} />
|
||||
<Skeleton variant="rounded" width={120} height={36} />
|
||||
</Box>
|
||||
<Skeleton variant="rectangular" width="100%" height={250} sx={{ borderRadius: 2 }} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'list') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Paper key={i} sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={24} />
|
||||
<Skeleton variant="text" width="40%" height={20} />
|
||||
</Box>
|
||||
<Skeleton variant="rounded" width={80} height={32} />
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||
<Skeleton variant="text" width={150} height={32} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="100%" height={24} />
|
||||
<Skeleton variant="text" width="90%" height={24} />
|
||||
<Skeleton variant="text" width="70%" height={24} />
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 1 }}>
|
||||
<Skeleton variant="rounded" width={100} height={36} />
|
||||
<Skeleton variant="rounded" width={100} height={36} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: full page skeleton
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Skeleton variant="text" width={300} height={48} />
|
||||
<Skeleton variant="text" width={500} height={24} sx={{ mt: 1 }} />
|
||||
</Box>
|
||||
|
||||
{/* Filter section */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Skeleton variant="rounded" width={200} height={56} />
|
||||
<Skeleton variant="rounded" width={300} height={56} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Stats cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3, mb: 3 }}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Paper key={i} sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Skeleton variant="circular" width={32} height={32} sx={{ mr: 1 }} />
|
||||
<Skeleton variant="text" width={100} height={28} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width={80} height={48} />
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Charts */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mb: 3 }}>
|
||||
{[1, 2].map((i) => (
|
||||
<Paper key={i} sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Skeleton variant="circular" width={24} height={24} sx={{ mr: 1 }} />
|
||||
<Skeleton variant="text" width={150} height={28} />
|
||||
</Box>
|
||||
<Skeleton variant="rectangular" width="100%" height={250} sx={{ borderRadius: 1 }} />
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Activity list */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Skeleton variant="text" width={200} height={32} sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 2, borderBottom: '1px solid', borderColor: 'divider', pb: 2 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="40%" height={24} />
|
||||
<Skeleton variant="text" width="60%" height={20} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
158
maternal-web/components/common/OfflineIndicator.tsx
Normal file
158
maternal-web/components/common/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, LinearProgress, Box, Typography } from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CloudOff, CloudQueue, CloudDone } from '@mui/icons-material';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
isOnline?: boolean;
|
||||
pendingActionsCount?: number;
|
||||
syncInProgress?: boolean;
|
||||
}
|
||||
|
||||
export const OfflineIndicator = ({
|
||||
isOnline: propIsOnline,
|
||||
pendingActionsCount = 0,
|
||||
syncInProgress = false,
|
||||
}: OfflineIndicatorProps) => {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial online status
|
||||
setIsOnline(navigator.onLine);
|
||||
|
||||
// Listen for online/offline events
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const effectiveIsOnline = propIsOnline !== undefined ? propIsOnline : isOnline;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{!effectiveIsOnline && (
|
||||
<motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<CloudOff />}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
boxShadow: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
You're offline
|
||||
</Typography>
|
||||
{pendingActionsCount > 0 && (
|
||||
<Typography variant="caption">
|
||||
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} will sync when you're back online
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{effectiveIsOnline && syncInProgress && (
|
||||
<motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<CloudQueue />}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
boxShadow: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
Syncing data...
|
||||
</Typography>
|
||||
{pendingActionsCount > 0 && (
|
||||
<Typography variant="caption">
|
||||
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} remaining
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
<LinearProgress />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{effectiveIsOnline && !syncInProgress && pendingActionsCount === 0 &&
|
||||
typeof propIsOnline !== 'undefined' && propIsOnline && (
|
||||
<motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
onAnimationComplete={() => {
|
||||
// Auto-hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById('sync-complete-alert');
|
||||
if (element) {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}, 3000);
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
id="sync-complete-alert"
|
||||
>
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CloudDone />}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
boxShadow: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
All data synced successfully!
|
||||
</Typography>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
maternal-web/components/common/OptimizedImage.tsx
Normal file
77
maternal-web/components/common/OptimizedImage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import Image, { ImageProps } from 'next/image';
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
|
||||
interface OptimizedImageProps extends Omit<ImageProps, 'onLoadingComplete'> {
|
||||
onLoadComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* OptimizedImage Component
|
||||
*
|
||||
* Wraps Next.js Image component with:
|
||||
* - Loading states with MUI Skeleton
|
||||
* - Blur placeholder support
|
||||
* - Loading completion events
|
||||
* - Automatic optimization
|
||||
*/
|
||||
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
onLoadComplete,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleLoadingComplete = () => {
|
||||
setIsLoading(false);
|
||||
onLoadComplete?.();
|
||||
};
|
||||
|
||||
// Generate a simple blur data URL for placeholder
|
||||
const blurDataURL = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width={width}
|
||||
height={height}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
animation="wave"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoadingComplete={handleLoadingComplete}
|
||||
placeholder="blur"
|
||||
blurDataURL={blurDataURL}
|
||||
style={{
|
||||
opacity: isLoading ? 0 : 1,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal file
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { initPerformanceMonitoring } from '@/lib/performance/monitoring';
|
||||
|
||||
/**
|
||||
* PerformanceMonitor Component
|
||||
*
|
||||
* Client-side component that initializes web vitals monitoring
|
||||
* Should be included once in the root layout
|
||||
*/
|
||||
export const PerformanceMonitor: React.FC = () => {
|
||||
useEffect(() => {
|
||||
// Initialize performance monitoring on client side
|
||||
initPerformanceMonitoring();
|
||||
}, []);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
};
|
||||
41
maternal-web/components/common/ProtectedRoute.tsx
Normal file
41
maternal-web/components/common/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password'];
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { LoadingFallback } from '../LoadingFallback'
|
||||
|
||||
describe('LoadingFallback', () => {
|
||||
it('renders without crashing for page variant', () => {
|
||||
const { container } = render(<LoadingFallback variant="page" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without crashing for card variant', () => {
|
||||
const { container } = render(<LoadingFallback variant="card" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without crashing for list variant', () => {
|
||||
const { container } = render(<LoadingFallback variant="list" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without crashing for chart variant', () => {
|
||||
const { container } = render(<LoadingFallback variant="chart" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without crashing for chat variant', () => {
|
||||
const { container } = render(<LoadingFallback variant="chat" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to page variant when no variant is specified', () => {
|
||||
const { container } = render(<LoadingFallback />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user