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
183 lines
6.1 KiB
TypeScript
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;
|