diff --git a/components/mobile/BottomNav.tsx b/components/mobile/BottomNav.tsx new file mode 100644 index 0000000..5263f64 --- /dev/null +++ b/components/mobile/BottomNav.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; + +interface NavItem { + href: string; + icon: React.ReactNode; + label: string; +} + +const navItems: NavItem[] = [ + { + href: '/m', + icon: ( + + + + ), + label: 'Home', + }, + { + href: '/m/scan', + icon: ( + + + + ), + label: 'Scan', + }, + { + href: '/m/quick-add', + icon: ( + + + + ), + label: 'Add', + }, + { + href: '/plants/explore', + icon: ( + + + + ), + label: 'Explore', + }, + { + href: '/m/profile', + icon: ( + + + + ), + label: 'Profile', + }, +]; + +export function BottomNav() { + const router = useRouter(); + const pathname = router.pathname; + + return ( + + ); +} + +export default BottomNav; diff --git a/components/mobile/InstallPrompt.tsx b/components/mobile/InstallPrompt.tsx new file mode 100644 index 0000000..311f285 --- /dev/null +++ b/components/mobile/InstallPrompt.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + prompt(): Promise; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = React.useState(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 ( +
+
+
+
+ {/* App Icon */} +
+
+ + + +
+
+ + {/* Content */} +
+

Install LocalGreenChain

+

+ {isIOS + ? 'Tap the share button and select "Add to Home Screen"' + : 'Install our app for a better experience with offline support'} +

+
+ + {/* Close button */} + +
+ + {/* iOS Instructions */} + {isIOS && ( +
+ + + + Then tap "Add to Home Screen" +
+ )} + + {/* Install Button (non-iOS) */} + {!isIOS && deferredPrompt && ( +
+ + +
+ )} +
+
+
+ ); +} + +export default InstallPrompt; diff --git a/components/mobile/MobileHeader.tsx b/components/mobile/MobileHeader.tsx new file mode 100644 index 0000000..58107b0 --- /dev/null +++ b/components/mobile/MobileHeader.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +interface MobileHeaderProps { + title?: string; + showBack?: boolean; + rightAction?: React.ReactNode; +} + +export function MobileHeader({ title, showBack = false, rightAction }: MobileHeaderProps) { + const router = useRouter(); + + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push('/m'); + } + }; + + return ( +
+
+ {/* Left side */} +
+ {showBack ? ( + + ) : ( + + +
+ + + +
+
+ + )} +
+ + {/* Center - Title */} +
+

{title || 'LocalGreenChain'}

+
+ + {/* Right side */} +
+ {rightAction || ( + + )} +
+
+
+ ); +} + +export default MobileHeader; diff --git a/components/mobile/PullToRefresh.tsx b/components/mobile/PullToRefresh.tsx new file mode 100644 index 0000000..274a81d --- /dev/null +++ b/components/mobile/PullToRefresh.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +interface PullToRefreshProps { + onRefresh: () => Promise; + children: React.ReactNode; + className?: string; +} + +export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) { + const containerRef = React.useRef(null); + const [startY, setStartY] = React.useState(0); + const [pullDistance, setPullDistance] = React.useState(0); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [isPulling, setIsPulling] = React.useState(false); + + const threshold = 80; + const maxPull = 120; + const resistance = 2.5; + + const handleTouchStart = (e: React.TouchEvent) => { + // Only start if scrolled to top + if (containerRef.current && containerRef.current.scrollTop === 0) { + setStartY(e.touches[0].clientY); + setIsPulling(true); + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isPulling || isRefreshing) return; + + const currentY = e.touches[0].clientY; + const diff = (currentY - startY) / resistance; + + if (diff > 0) { + const distance = Math.min(maxPull, diff); + setPullDistance(distance); + + // Prevent default scroll when pulling + if (containerRef.current && containerRef.current.scrollTop === 0) { + e.preventDefault(); + } + } + }; + + const handleTouchEnd = async () => { + if (!isPulling || isRefreshing) return; + + setIsPulling(false); + + if (pullDistance >= threshold) { + setIsRefreshing(true); + setPullDistance(60); // Keep indicator visible during refresh + + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + setPullDistance(0); + } + } else { + setPullDistance(0); + } + }; + + const progress = Math.min(1, pullDistance / threshold); + const rotation = pullDistance * 3; + + return ( +
+ {/* Pull indicator */} +
10 ? 1 : 0, + }} + > +
+ + + +
+
+ + {/* Pull text */} + {pullDistance > 10 && !isRefreshing && ( +
+ {pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'} +
+ )} + + {/* Content */} +
+ {children} +
+
+ ); +} + +export default PullToRefresh; diff --git a/components/mobile/QRScanner.tsx b/components/mobile/QRScanner.tsx new file mode 100644 index 0000000..8eeb793 --- /dev/null +++ b/components/mobile/QRScanner.tsx @@ -0,0 +1,196 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +interface QRScannerProps { + onScan: (result: string) => void; + onError?: (error: Error) => void; + onClose?: () => void; + className?: string; +} + +export function QRScanner({ onScan, onError, onClose, className }: QRScannerProps) { + const videoRef = React.useRef(null); + const canvasRef = React.useRef(null); + const [isScanning, setIsScanning] = React.useState(false); + const [hasCamera, setHasCamera] = React.useState(true); + const [cameraError, setCameraError] = React.useState(null); + const streamRef = React.useRef(null); + + const startCamera = React.useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + setIsScanning(true); + } + } catch (err) { + const error = err as Error; + setHasCamera(false); + setCameraError(error.message); + onError?.(error); + } + }, [onError]); + + const stopCamera = React.useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + setIsScanning(false); + }, []); + + React.useEffect(() => { + startCamera(); + return () => stopCamera(); + }, [startCamera, stopCamera]); + + // Simple QR detection simulation (in production, use a library like jsQR) + React.useEffect(() => { + if (!isScanning) return; + + const scanInterval = setInterval(() => { + if (videoRef.current && canvasRef.current) { + const canvas = canvasRef.current; + const video = videoRef.current; + const ctx = canvas.getContext('2d'); + + if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + // In production, use jsQR library here: + // const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + // const code = jsQR(imageData.data, imageData.width, imageData.height); + // if (code) { + // stopCamera(); + // onScan(code.data); + // } + } + } + }, 100); + + return () => clearInterval(scanInterval); + }, [isScanning, onScan, stopCamera]); + + // Demo function to simulate a scan + const simulateScan = () => { + stopCamera(); + onScan('plant:abc123-tomato-heirloom'); + }; + + if (!hasCamera) { + return ( +
+ + + + +

Camera access denied

+

{cameraError}

+ +
+ ); + } + + return ( +
+ {/* Video feed */} +