localgreenchain/components/mobile/InstallPrompt.tsx
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
This implements the mobile optimization agent (P3 - Enhancement) with:

PWA Configuration:
- Add next-pwa integration with offline caching strategies
- Create web app manifest for installability
- Add service worker with background sync support
- Create offline fallback page

Mobile Components:
- BottomNav: Touch-friendly bottom navigation bar
- MobileHeader: Responsive header with back navigation
- InstallPrompt: Smart PWA install prompt (iOS & Android)
- SwipeableCard: Gesture-based swipeable cards
- PullToRefresh: Native-like pull to refresh
- QRScanner: Camera-based QR code scanning

Mobile Library:
- camera.ts: Camera access and photo capture utilities
- offline.ts: IndexedDB-based offline storage and sync
- gestures.ts: Touch gesture detection (swipe, pinch, tap)
- pwa.ts: PWA status, install prompts, service worker management

Mobile Pages:
- /m: Mobile dashboard with quick actions and stats
- /m/scan: QR code scanner for plant lookup
- /m/quick-add: Streamlined plant registration form
- /m/profile: User profile with offline status

Dependencies added: next-pwa, idb
2025-11-23 03:56:30 +00:00

183 lines
6.1 KiB
TypeScript

import * as React from 'react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = React.useState(false);
const [isIOS, setIsIOS] = React.useState(false);
const [isInstalled, setIsInstalled] = React.useState(false);
React.useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
// Check if iOS
const ua = window.navigator.userAgent;
const isIOSDevice = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
setIsIOS(isIOSDevice);
// Check if user has dismissed the prompt recently
const dismissedAt = localStorage.getItem('pwa-prompt-dismissed');
if (dismissedAt) {
const dismissedTime = new Date(dismissedAt).getTime();
const now = Date.now();
const dayInMs = 24 * 60 * 60 * 1000;
if (now - dismissedTime < 7 * dayInMs) {
return;
}
}
// For non-iOS devices, wait for the beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// For iOS, show prompt after a delay if not installed
if (isIOSDevice && !navigator.standalone) {
const timer = setTimeout(() => {
setShowPrompt(true);
}, 3000);
return () => clearTimeout(timer);
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setShowPrompt(false);
setIsInstalled(true);
}
setDeferredPrompt(null);
};
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('pwa-prompt-dismissed', new Date().toISOString());
};
if (!showPrompt || isInstalled) {
return null;
}
return (
<div className="fixed bottom-20 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm animate-slide-up">
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
<div className="p-4">
<div className="flex items-start space-x-3">
{/* App Icon */}
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-600 rounded-xl flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-7 w-7 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900">Install LocalGreenChain</h3>
<p className="mt-1 text-sm text-gray-500">
{isIOS
? 'Tap the share button and select "Add to Home Screen"'
: 'Install our app for a better experience with offline support'}
</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 rounded-full hover:bg-gray-100"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* iOS Instructions */}
{isIOS && (
<div className="mt-3 flex items-center space-x-2 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
<span>Then tap "Add to Home Screen"</span>
</div>
)}
{/* Install Button (non-iOS) */}
{!isIOS && deferredPrompt && (
<div className="mt-3 flex space-x-3">
<button
onClick={handleDismiss}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"
>
Install
</button>
</div>
)}
</div>
</div>
</div>
);
}
export default InstallPrompt;