Files
maternal-app/maternal-web/components/pwa/InstallPrompt.tsx
Andrei 898a76c83a
Some checks failed
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: Complete PWA implementation with offline support and install prompts
PWA Features Implemented:
 Offline Fallback Page (/offline)
  - User-friendly offline page with connection status
  - Auto-redirect when back online
  - Lists available offline features
  - Retry and home navigation buttons

 Install Prompt UI (InstallPrompt component)
  - beforeinstallprompt event handler for Android/Desktop
  - iOS-specific install instructions with Share icon
  - Smart dismissal with 7-day cooldown
  - Already-installed detection

 Background Sync for Pending Actions
  - useBackgroundSync hook with multiple sync triggers
  - Periodic sync every 5 minutes when online
  - Sync on tab visibility change
  - Service Worker sync registration
  - BackgroundSyncProvider integration

 next-pwa Configuration Updates
  - Offline fallback to /offline page
  - Network timeout (10s) for better offline detection
  - skipWaiting and clientsClaim enabled
  - Runtime caching with NetworkFirst strategy

Files Created:
- app/offline/page.tsx (131 lines)
- components/pwa/InstallPrompt.tsx (164 lines)
- hooks/useBackgroundSync.ts (71 lines)
- components/providers/BackgroundSyncProvider.tsx (10 lines)

Files Modified:
- app/layout.tsx (added InstallPrompt and BackgroundSyncProvider)
- next.config.mjs (offline fallback + workbox options)

Total: 376 new lines across 4 new files + 2 modified files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 07:38:47 +00:00

175 lines
5.3 KiB
TypeScript

'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<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(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 (
<Snackbar
open={showInstallPrompt}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="info"
icon={<Apple />}
action={
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={handleClose}
>
<Close fontSize="small" />
</IconButton>
}
sx={{ width: '100%', maxWidth: 500 }}
>
<Box>
<strong>Install Maternal App</strong>
<br />
Tap the Share button <Box component="span" sx={{ fontSize: 20 }}></Box>, then "Add to Home Screen"
</Box>
</Alert>
</Snackbar>
);
}
// Android/Desktop install prompt
if (deferredPrompt && showInstallPrompt) {
return (
<Snackbar
open={showInstallPrompt}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="info"
icon={<Android />}
action={
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={handleClose}
>
<Close fontSize="small" />
</IconButton>
}
sx={{ width: '100%', maxWidth: 500 }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<strong>Install Maternal App</strong>
<br />
Get quick access from your home screen
</Box>
<Button
variant="contained"
size="small"
startIcon={<GetApp />}
onClick={handleInstallClick}
sx={{ flexShrink: 0 }}
>
Install
</Button>
</Box>
</Alert>
</Snackbar>
);
}
return null;
}