diff --git a/maternal-web/app/layout.tsx b/maternal-web/app/layout.tsx index 82a30e8..7d45f34 100644 --- a/maternal-web/app/layout.tsx +++ b/maternal-web/app/layout.tsx @@ -8,6 +8,8 @@ import { AxeProvider } from '@/components/providers/AxeProvider'; import { SkipNavigation } from '@/components/common/SkipNavigation'; import { VoiceFloatingButton } from '@/components/voice/VoiceFloatingButton'; import { FocusManagementProvider } from '@/components/providers/FocusManagementProvider'; +import { BackgroundSyncProvider } from '@/components/providers/BackgroundSyncProvider'; +import { InstallPrompt } from '@/components/pwa/InstallPrompt'; // import { PerformanceMonitor } from '@/components/common/PerformanceMonitor'; // Temporarily disabled import './globals.css'; @@ -48,16 +50,19 @@ export default function RootLayout({ - - - - {/* */} -
- {children} -
- -
-
+ + + + + {/* */} +
+ {children} +
+ + +
+
+
diff --git a/maternal-web/app/offline/page.tsx b/maternal-web/app/offline/page.tsx new file mode 100644 index 0000000..ae5b43a --- /dev/null +++ b/maternal-web/app/offline/page.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Box, Container, Typography, Button, Paper } from '@mui/material'; +import { WifiOff, Refresh, Home } from '@mui/icons-material'; +import { useRouter } from 'next/navigation'; + +export default function OfflinePage() { + const router = useRouter(); + const [isOnline, setIsOnline] = useState(false); + + useEffect(() => { + // Check online status + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + setIsOnline(navigator.onLine); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + useEffect(() => { + // Redirect when back online + if (isOnline) { + router.push('/'); + } + }, [isOnline, router]); + + const handleRetry = () => { + if (navigator.onLine) { + router.push('/'); + } else { + window.location.reload(); + } + }; + + const handleGoHome = () => { + router.push('/'); + }; + + return ( + + + + + + + You're Offline + + + + It looks like you've lost your internet connection. Some features may be limited while offline. + + + + + What you can still do: + + +
  • + + View previously loaded activities and data + +
  • +
  • + + Create new activities (will sync when back online) + +
  • +
  • + + Access cached pages and information + +
  • +
    +
    + + + + + + + {isOnline && ( + + ✓ Connection restored! Redirecting... + + )} +
    +
    +
    + ); +} diff --git a/maternal-web/components/providers/BackgroundSyncProvider.tsx b/maternal-web/components/providers/BackgroundSyncProvider.tsx new file mode 100644 index 0000000..c590b59 --- /dev/null +++ b/maternal-web/components/providers/BackgroundSyncProvider.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ReactNode } from 'react'; +import { useBackgroundSync } from '@/hooks/useBackgroundSync'; + +export function BackgroundSyncProvider({ children }: { children: ReactNode }) { + // Initialize background sync + useBackgroundSync(); + + return <>{children}; +} diff --git a/maternal-web/components/pwa/InstallPrompt.tsx b/maternal-web/components/pwa/InstallPrompt.tsx new file mode 100644 index 0000000..c7954ea --- /dev/null +++ b/maternal-web/components/pwa/InstallPrompt.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Box, Button, Snackbar, Alert, IconButton } from '@mui/material'; +import { GetApp, Close, Apple, Android } from '@mui/icons-material'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showInstallPrompt, setShowInstallPrompt] = useState(false); + const [isIOS, setIsIOS] = useState(false); + const [isStandalone, setIsStandalone] = useState(false); + + useEffect(() => { + // Check if app is already installed + const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || + (window.navigator as any).standalone === true; + setIsStandalone(isStandaloneMode); + + // Check if iOS + const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; + setIsIOS(iOS); + + // Handle beforeinstallprompt event (Android/Desktop) + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + const installEvent = e as BeforeInstallPromptEvent; + setDeferredPrompt(installEvent); + + // Check if user has dismissed the prompt before + const installDismissed = localStorage.getItem('pwa-install-dismissed'); + const dismissedTime = installDismissed ? parseInt(installDismissed) : 0; + const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24); + + // Show prompt if not dismissed or dismissed more than 7 days ago + if (!installDismissed || daysSinceDismissed > 7) { + setShowInstallPrompt(true); + } + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + // For iOS, check if we should show the prompt + if (iOS && !isStandaloneMode) { + const installDismissed = localStorage.getItem('pwa-install-dismissed-ios'); + const dismissedTime = installDismissed ? parseInt(installDismissed) : 0; + const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24); + + if (!installDismissed || daysSinceDismissed > 7) { + setShowInstallPrompt(true); + } + } + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, []); + + const handleInstallClick = async () => { + if (!deferredPrompt) return; + + // Show the install prompt + await deferredPrompt.prompt(); + + // Wait for the user to respond to the prompt + const choiceResult = await deferredPrompt.userChoice; + + if (choiceResult.outcome === 'accepted') { + console.log('User accepted the install prompt'); + } else { + console.log('User dismissed the install prompt'); + localStorage.setItem('pwa-install-dismissed', Date.now().toString()); + } + + // Clear the deferredPrompt + setDeferredPrompt(null); + setShowInstallPrompt(false); + }; + + const handleClose = () => { + setShowInstallPrompt(false); + if (isIOS) { + localStorage.setItem('pwa-install-dismissed-ios', Date.now().toString()); + } else { + localStorage.setItem('pwa-install-dismissed', Date.now().toString()); + } + }; + + // Don't show if already installed + if (isStandalone) return null; + + // iOS install instructions + if (isIOS && showInstallPrompt) { + return ( + + } + action={ + + + + } + sx={{ width: '100%', maxWidth: 500 }} + > + + Install Maternal App +
    + Tap the Share button , then "Add to Home Screen" +
    +
    +
    + ); + } + + // Android/Desktop install prompt + if (deferredPrompt && showInstallPrompt) { + return ( + + } + action={ + + + + } + sx={{ width: '100%', maxWidth: 500 }} + > + + + Install Maternal App +
    + Get quick access from your home screen +
    + +
    +
    +
    + ); + } + + return null; +} diff --git a/maternal-web/hooks/useBackgroundSync.ts b/maternal-web/hooks/useBackgroundSync.ts new file mode 100644 index 0000000..425a44d --- /dev/null +++ b/maternal-web/hooks/useBackgroundSync.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { setOnlineStatus } from '@/store/slices/networkSlice'; + +export function useBackgroundSync() { + const dispatch = useAppDispatch(); + const { isOnline } = useAppSelector((state) => state.network); + const { pendingActions } = useAppSelector((state) => state.offline); + const syncIntervalRef = useRef(null); + + useEffect(() => { + // Register background sync if supported + if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { + navigator.serviceWorker.ready.then((registration) => { + // Register a sync event + return (registration as any).sync.register('sync-pending-actions'); + }).catch((error) => { + console.error('Background sync registration failed:', error); + }); + } + + // Listen for sync events + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data && event.data.type === 'BACKGROUND_SYNC') { + // Trigger sync from service worker by simulating online status + dispatch(setOnlineStatus(true)); + } + }); + } + }, [dispatch]); + + useEffect(() => { + // Periodic sync every 5 minutes when online and have pending actions + if (isOnline && pendingActions.length > 0) { + syncIntervalRef.current = setInterval(() => { + console.log('Periodic sync check...'); + // Trigger sync by re-dispatching online status + dispatch(setOnlineStatus(true)); + }, 5 * 60 * 1000); // 5 minutes + } + + return () => { + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current); + } + }; + }, [isOnline, pendingActions.length, dispatch]); + + // Sync on visibility change (when user returns to tab) + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && isOnline && pendingActions.length > 0) { + console.log('Tab visible, syncing pending actions...'); + // Trigger sync by re-dispatching online status + dispatch(setOnlineStatus(true)); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isOnline, pendingActions.length, dispatch]); + + return { + pendingCount: pendingActions.length, + isOnline, + }; +} diff --git a/maternal-web/next.config.mjs b/maternal-web/next.config.mjs index 3185319..736faed 100644 --- a/maternal-web/next.config.mjs +++ b/maternal-web/next.config.mjs @@ -13,6 +13,9 @@ const pwaConfig = withPWA({ register: true, skipWaiting: true, disable: process.env.NODE_ENV === 'development', + fallbacks: { + document: '/offline', + }, runtimeCaching: [ { urlPattern: /^https?.*/, @@ -22,9 +25,14 @@ const pwaConfig = withPWA({ expiration: { maxEntries: 200, }, + networkTimeoutSeconds: 10, }, }, ], + workboxOptions: { + skipWaiting: true, + clientsClaim: true, + }, }); export default pwaConfig(nextConfig);