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
+
+
+
+
+
+
+ }
+ onClick={handleRetry}
+ size="large"
+ >
+ Try Again
+
+ }
+ onClick={handleGoHome}
+ size="large"
+ >
+ Go Home
+
+
+
+ {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
+
+ }
+ onClick={handleInstallClick}
+ sx={{ flexShrink: 0 }}
+ >
+ Install
+
+
+
+
+ );
+ }
+
+ 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);