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>
175 lines
5.3 KiB
TypeScript
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;
|
|
}
|