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
This commit is contained in:
Claude 2025-11-23 03:56:30 +00:00
parent 705105d9b6
commit c2a1b05677
No known key found for this signature in database
25 changed files with 4051 additions and 2 deletions

View file

@ -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: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
label: 'Home',
},
{
href: '/m/scan',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
),
label: 'Scan',
},
{
href: '/m/quick-add',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
),
label: 'Add',
},
{
href: '/plants/explore',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
label: 'Explore',
},
{
href: '/m/profile',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
label: 'Profile',
},
];
export function BottomNav() {
const router = useRouter();
const pathname = router.pathname;
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 pb-safe md:hidden">
<div className="flex items-center justify-around h-16">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link key={item.href} href={item.href}>
<a
className={classNames(
'flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors',
{
'text-green-600': isActive,
'text-gray-500 hover:text-gray-700': !isActive,
}
)}
>
{item.icon}
<span className="text-xs font-medium">{item.label}</span>
</a>
</Link>
);
})}
</div>
</nav>
);
}
export default BottomNav;

View file

@ -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<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;

View file

@ -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 (
<header className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 pt-safe md:hidden">
<div className="flex items-center justify-between h-14 px-4">
{/* Left side */}
<div className="flex items-center w-20">
{showBack ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-10 h-10 -ml-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
aria-label="Go back"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-gray-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
) : (
<Link href="/m">
<a className="flex items-center space-x-2">
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 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>
</a>
</Link>
)}
</div>
{/* Center - Title */}
<div className="flex-1 text-center">
<h1 className="text-lg font-semibold text-gray-900 truncate">{title || 'LocalGreenChain'}</h1>
</div>
{/* Right side */}
<div className="flex items-center justify-end w-20">
{rightAction || (
<button
className="flex items-center justify-center w-10 h-10 -mr-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
aria-label="Notifications"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-gray-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
</div>
</div>
</header>
);
}
export default MobileHeader;

View file

@ -0,0 +1,138 @@
import * as React from 'react';
import classNames from 'classnames';
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
className?: string;
}
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
const containerRef = React.useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={classNames('relative overflow-auto', className)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Pull indicator */}
<div
className="absolute left-1/2 transform -translate-x-1/2 z-10 transition-opacity"
style={{
top: pullDistance - 40,
opacity: pullDistance > 10 ? 1 : 0,
}}
>
<div
className={classNames(
'w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center',
{ 'animate-spin': isRefreshing }
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={classNames('h-6 w-6 text-green-600 transition-transform', {
'animate-spin': isRefreshing,
})}
style={{ transform: isRefreshing ? undefined : `rotate(${rotation}deg)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
</div>
{/* Pull text */}
{pullDistance > 10 && !isRefreshing && (
<div
className="absolute left-1/2 transform -translate-x-1/2 text-sm text-gray-500 transition-opacity z-10"
style={{
top: pullDistance + 5,
opacity: progress,
}}
>
{pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'}
</div>
)}
{/* Content */}
<div
className="transition-transform"
style={{
transform: `translateY(${pullDistance}px)`,
transitionDuration: isPulling ? '0ms' : '200ms',
}}
>
{children}
</div>
</div>
);
}
export default PullToRefresh;

View file

@ -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<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = React.useState(false);
const [hasCamera, setHasCamera] = React.useState(true);
const [cameraError, setCameraError] = React.useState<string | null>(null);
const streamRef = React.useRef<MediaStream | null>(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 (
<div className={classNames('flex flex-col items-center justify-center p-8 bg-gray-100 rounded-lg', className)}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-16 w-16 text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-gray-600 text-center mb-2">Camera access denied</p>
<p className="text-sm text-gray-500 text-center">{cameraError}</p>
<button
onClick={startCamera}
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={classNames('relative overflow-hidden rounded-lg bg-black', className)}>
{/* Video feed */}
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
{/* Scanning overlay */}
<div className="absolute inset-0 flex items-center justify-center">
{/* Darkened corners */}
<div className="absolute inset-0 bg-black/50" />
{/* Transparent scanning area */}
<div className="relative w-64 h-64">
{/* Cut out the scanning area */}
<div className="absolute inset-0 border-2 border-white rounded-lg" />
{/* Corner markers */}
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-green-500 rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-green-500 rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-green-500 rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-green-500 rounded-br-lg" />
{/* Scanning line animation */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-green-500 animate-scan-line" />
</div>
</div>
{/* Instructions */}
<div className="absolute bottom-20 left-0 right-0 text-center">
<p className="text-white text-sm bg-black/50 inline-block px-4 py-2 rounded-full">
Point camera at QR code
</p>
</div>
{/* Demo scan button (remove in production) */}
<button
onClick={simulateScan}
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 px-6 py-2 bg-green-600 text-white rounded-full text-sm font-medium shadow-lg"
>
Demo: Simulate Scan
</button>
{/* Close button */}
{onClose && (
<button
onClick={() => {
stopCamera();
onClose();
}}
className="absolute top-4 right-4 w-10 h-10 bg-black/50 rounded-full flex items-center justify-center"
aria-label="Close scanner"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
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>
);
}
export default QRScanner;

View file

@ -0,0 +1,131 @@
import * as React from 'react';
import classNames from 'classnames';
interface SwipeableCardProps {
children: React.ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
leftAction?: React.ReactNode;
rightAction?: React.ReactNode;
className?: string;
}
export function SwipeableCard({
children,
onSwipeLeft,
onSwipeRight,
leftAction,
rightAction,
className,
}: SwipeableCardProps) {
const cardRef = React.useRef<HTMLDivElement>(null);
const [startX, setStartX] = React.useState(0);
const [currentX, setCurrentX] = React.useState(0);
const [isSwiping, setIsSwiping] = React.useState(false);
const threshold = 100;
const maxSwipe = 150;
const handleTouchStart = (e: React.TouchEvent) => {
setStartX(e.touches[0].clientX);
setIsSwiping(true);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwiping) return;
const diff = e.touches[0].clientX - startX;
const clampedDiff = Math.max(-maxSwipe, Math.min(maxSwipe, diff));
// Only allow swiping in directions that have actions
if (diff > 0 && !onSwipeRight) return;
if (diff < 0 && !onSwipeLeft) return;
setCurrentX(clampedDiff);
};
const handleTouchEnd = () => {
setIsSwiping(false);
if (currentX > threshold && onSwipeRight) {
onSwipeRight();
} else if (currentX < -threshold && onSwipeLeft) {
onSwipeLeft();
}
setCurrentX(0);
};
const swipeProgress = Math.abs(currentX) / threshold;
const direction = currentX > 0 ? 'right' : 'left';
return (
<div className={classNames('relative overflow-hidden rounded-lg', className)}>
{/* Left action background */}
{rightAction && (
<div
className={classNames(
'absolute inset-y-0 left-0 flex items-center justify-start pl-4 bg-green-500 transition-opacity',
{
'opacity-100': currentX > 0,
'opacity-0': currentX <= 0,
}
)}
style={{ width: Math.abs(currentX) }}
>
{rightAction}
</div>
)}
{/* Right action background */}
{leftAction && (
<div
className={classNames(
'absolute inset-y-0 right-0 flex items-center justify-end pr-4 bg-red-500 transition-opacity',
{
'opacity-100': currentX < 0,
'opacity-0': currentX >= 0,
}
)}
style={{ width: Math.abs(currentX) }}
>
{leftAction}
</div>
)}
{/* Main card content */}
<div
ref={cardRef}
className="relative bg-white transition-transform touch-pan-y"
style={{
transform: `translateX(${currentX}px)`,
transitionDuration: isSwiping ? '0ms' : '200ms',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
{/* Swipe indicator */}
{isSwiping && Math.abs(currentX) > 20 && (
<div
className={classNames(
'absolute top-1/2 transform -translate-y-1/2 text-white text-sm font-medium',
{
'left-4': direction === 'right',
'right-4': direction === 'left',
}
)}
style={{ opacity: swipeProgress }}
>
{direction === 'right' && onSwipeRight && 'Release to confirm'}
{direction === 'left' && onSwipeLeft && 'Release to delete'}
</div>
)}
</div>
);
}
export default SwipeableCard;

View file

@ -0,0 +1,6 @@
export { BottomNav } from './BottomNav';
export { MobileHeader } from './MobileHeader';
export { InstallPrompt } from './InstallPrompt';
export { SwipeableCard } from './SwipeableCard';
export { PullToRefresh } from './PullToRefresh';
export { QRScanner } from './QRScanner';

254
lib/mobile/camera.ts Normal file
View file

@ -0,0 +1,254 @@
/**
* Mobile Camera Utilities
* Provides camera access, photo capture, and image processing for mobile devices
*/
export interface CameraConfig {
facingMode?: 'user' | 'environment';
width?: number;
height?: number;
aspectRatio?: number;
}
export interface CapturedImage {
blob: Blob;
dataUrl: string;
width: number;
height: number;
}
export class CameraService {
private stream: MediaStream | null = null;
private videoElement: HTMLVideoElement | null = null;
async checkCameraAvailability(): Promise<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === 'videoinput');
} catch {
return false;
}
}
async requestPermission(): Promise<PermissionState> {
try {
const result = await navigator.permissions.query({ name: 'camera' as PermissionName });
return result.state;
} catch {
// Fallback: try to access camera to trigger permission prompt
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((track) => track.stop());
return 'granted';
} catch {
return 'denied';
}
}
}
async startCamera(videoElement: HTMLVideoElement, config: CameraConfig = {}): Promise<void> {
const {
facingMode = 'environment',
width = 1280,
height = 720,
aspectRatio,
} = config;
const constraints: MediaStreamConstraints = {
video: {
facingMode,
width: { ideal: width },
height: { ideal: height },
...(aspectRatio && { aspectRatio }),
},
};
try {
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
videoElement.srcObject = this.stream;
this.videoElement = videoElement;
await videoElement.play();
} catch (error) {
throw new Error(`Failed to start camera: ${(error as Error).message}`);
}
}
async capturePhoto(quality = 0.9): Promise<CapturedImage> {
if (!this.videoElement || !this.stream) {
throw new Error('Camera not started');
}
const canvas = document.createElement('canvas');
const video = this.videoElement;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to capture photo'));
return;
}
const dataUrl = canvas.toDataURL('image/jpeg', quality);
resolve({
blob,
dataUrl,
width: canvas.width,
height: canvas.height,
});
},
'image/jpeg',
quality
);
});
}
async switchCamera(): Promise<void> {
if (!this.videoElement) {
throw new Error('Camera not started');
}
const currentTrack = this.stream?.getVideoTracks()[0];
const currentFacingMode = currentTrack?.getSettings().facingMode;
const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
this.stopCamera();
await this.startCamera(this.videoElement, { facingMode: newFacingMode });
}
stopCamera(): void {
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
if (this.videoElement) {
this.videoElement.srcObject = null;
this.videoElement = null;
}
}
isActive(): boolean {
return this.stream !== null && this.stream.active;
}
}
// Image processing utilities
export async function cropImage(
image: CapturedImage,
x: number,
y: number,
width: number,
height: number
): Promise<CapturedImage> {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
const img = await loadImage(image.dataUrl);
ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to crop image'));
return;
}
resolve({
blob,
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width,
height,
});
},
'image/jpeg',
0.9
);
});
}
export async function resizeImage(
image: CapturedImage,
maxWidth: number,
maxHeight: number
): Promise<CapturedImage> {
const img = await loadImage(image.dataUrl);
let { width, height } = img;
const ratio = Math.min(maxWidth / width, maxHeight / height);
if (ratio < 1) {
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.drawImage(img, 0, 0, width, height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to resize image'));
return;
}
resolve({
blob,
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width,
height,
});
},
'image/jpeg',
0.9
);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Singleton instance
let cameraInstance: CameraService | null = null;
export function getCamera(): CameraService {
if (!cameraInstance) {
cameraInstance = new CameraService();
}
return cameraInstance;
}
export default CameraService;

257
lib/mobile/gestures.ts Normal file
View file

@ -0,0 +1,257 @@
/**
* Mobile Gesture Utilities
* Provides touch gesture detection and handling
*/
export interface GestureEvent {
type: 'swipe' | 'pinch' | 'rotate' | 'tap' | 'longpress' | 'doubletap';
direction?: 'up' | 'down' | 'left' | 'right';
scale?: number;
rotation?: number;
center?: { x: number; y: number };
velocity?: { x: number; y: number };
}
export interface GestureHandlers {
onSwipe?: (direction: 'up' | 'down' | 'left' | 'right', velocity: number) => void;
onPinch?: (scale: number) => void;
onRotate?: (angle: number) => void;
onTap?: (x: number, y: number) => void;
onLongPress?: (x: number, y: number) => void;
onDoubleTap?: (x: number, y: number) => void;
}
export interface GestureConfig {
swipeThreshold?: number;
swipeVelocityThreshold?: number;
longPressDelay?: number;
doubleTapDelay?: number;
pinchThreshold?: number;
}
const defaultConfig: Required<GestureConfig> = {
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
longPressDelay: 500,
doubleTapDelay: 300,
pinchThreshold: 0.1,
};
export function createGestureHandler(
element: HTMLElement,
handlers: GestureHandlers,
config: GestureConfig = {}
): () => void {
const cfg = { ...defaultConfig, ...config };
let startX = 0;
let startY = 0;
let startTime = 0;
let longPressTimer: NodeJS.Timeout | null = null;
let lastTapTime = 0;
let initialDistance = 0;
let initialAngle = 0;
const getDistance = (touches: TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
return Math.sqrt(dx * dx + dy * dy);
};
const getAngle = (touches: TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
return (Math.atan2(dy, dx) * 180) / Math.PI;
};
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
// Long press detection
if (handlers.onLongPress) {
longPressTimer = setTimeout(() => {
handlers.onLongPress!(startX, startY);
// Vibrate on long press if available
if ('vibrate' in navigator) {
navigator.vibrate(50);
}
}, cfg.longPressDelay);
}
// Pinch/rotate initialization
if (e.touches.length === 2) {
initialDistance = getDistance(e.touches);
initialAngle = getAngle(e.touches);
}
};
const handleTouchMove = (e: TouchEvent) => {
// Cancel long press on move
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// Pinch detection
if (e.touches.length === 2 && (handlers.onPinch || handlers.onRotate)) {
const currentDistance = getDistance(e.touches);
const currentAngle = getAngle(e.touches);
if (handlers.onPinch && initialDistance > 0) {
const scale = currentDistance / initialDistance;
if (Math.abs(scale - 1) > cfg.pinchThreshold) {
handlers.onPinch(scale);
}
}
if (handlers.onRotate) {
const rotation = currentAngle - initialAngle;
handlers.onRotate(rotation);
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
// Clear long press timer
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
const touch = e.changedTouches[0];
const endX = touch.clientX;
const endY = touch.clientY;
const endTime = Date.now();
const deltaX = endX - startX;
const deltaY = endY - startY;
const deltaTime = endTime - startTime;
// Swipe detection
if (handlers.onSwipe) {
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
if (velocity > cfg.swipeVelocityThreshold) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (Math.abs(deltaX) > cfg.swipeThreshold) {
handlers.onSwipe(deltaX > 0 ? 'right' : 'left', velocity);
}
} else {
if (Math.abs(deltaY) > cfg.swipeThreshold) {
handlers.onSwipe(deltaY > 0 ? 'down' : 'up', velocity);
}
}
}
}
// Tap / Double tap detection
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
const now = Date.now();
if (handlers.onDoubleTap && now - lastTapTime < cfg.doubleTapDelay) {
handlers.onDoubleTap(endX, endY);
lastTapTime = 0;
} else if (handlers.onTap) {
lastTapTime = now;
// Delay tap to wait for potential double tap
if (handlers.onDoubleTap) {
setTimeout(() => {
if (lastTapTime === now) {
handlers.onTap!(endX, endY);
}
}, cfg.doubleTapDelay);
} else {
handlers.onTap(endX, endY);
}
}
}
};
const handleTouchCancel = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
// Add event listeners
element.addEventListener('touchstart', handleTouchStart, { passive: true });
element.addEventListener('touchmove', handleTouchMove, { passive: true });
element.addEventListener('touchend', handleTouchEnd, { passive: true });
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
// Return cleanup function
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
element.removeEventListener('touchend', handleTouchEnd);
element.removeEventListener('touchcancel', handleTouchCancel);
if (longPressTimer) {
clearTimeout(longPressTimer);
}
};
}
// React hook for gestures
export function useGestures(
ref: React.RefObject<HTMLElement>,
handlers: GestureHandlers,
config?: GestureConfig
): void {
if (typeof window === 'undefined') return;
const handlersRef = { current: handlers };
handlersRef.current = handlers;
const element = ref.current;
if (!element) return;
// Note: In actual React usage, this should be inside a useEffect
createGestureHandler(element, handlersRef.current, config);
}
// Haptic feedback utility
export function triggerHaptic(type: 'light' | 'medium' | 'heavy' = 'light'): void {
if (!('vibrate' in navigator)) return;
const patterns: Record<typeof type, number | number[]> = {
light: 10,
medium: 25,
heavy: [50, 50, 50],
};
navigator.vibrate(patterns[type]);
}
// Prevent pull-to-refresh on specific elements
export function preventPullToRefresh(element: HTMLElement): () => void {
let startY = 0;
const handleTouchStart = (e: TouchEvent) => {
startY = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const scrollTop = element.scrollTop;
// Prevent if at top and pulling down
if (scrollTop === 0 && currentY > startY) {
e.preventDefault();
}
};
element.addEventListener('touchstart', handleTouchStart, { passive: true });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
};
}

4
lib/mobile/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from './camera';
export * from './offline';
export * from './gestures';
export * from './pwa';

275
lib/mobile/offline.ts Normal file
View file

@ -0,0 +1,275 @@
/**
* Offline Support Utilities
* Provides IndexedDB storage and background sync for offline functionality
*/
import { openDB, DBSchema, IDBPDatabase } from 'idb';
// Database schema
interface LocalGreenChainDB extends DBSchema {
'pending-plants': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'pending-transport': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'cached-plants': {
key: string;
value: {
id: string;
data: any;
cachedAt: string;
};
indexes: { 'by-cached': string };
};
'user-preferences': {
key: string;
value: any;
};
}
const DB_NAME = 'localgreenchain-offline';
const DB_VERSION = 1;
let dbPromise: Promise<IDBPDatabase<LocalGreenChainDB>> | null = null;
async function getDB(): Promise<IDBPDatabase<LocalGreenChainDB>> {
if (!dbPromise) {
dbPromise = openDB<LocalGreenChainDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Pending plants store
if (!db.objectStoreNames.contains('pending-plants')) {
const plantStore = db.createObjectStore('pending-plants', { keyPath: 'id' });
plantStore.createIndex('by-created', 'createdAt');
}
// Pending transport store
if (!db.objectStoreNames.contains('pending-transport')) {
const transportStore = db.createObjectStore('pending-transport', { keyPath: 'id' });
transportStore.createIndex('by-created', 'createdAt');
}
// Cached plants store
if (!db.objectStoreNames.contains('cached-plants')) {
const cacheStore = db.createObjectStore('cached-plants', { keyPath: 'id' });
cacheStore.createIndex('by-cached', 'cachedAt');
}
// User preferences store
if (!db.objectStoreNames.contains('user-preferences')) {
db.createObjectStore('user-preferences');
}
},
});
}
return dbPromise;
}
// Network status
export function isOnline(): boolean {
return typeof navigator !== 'undefined' ? navigator.onLine : true;
}
export function onNetworkChange(callback: (online: boolean) => void): () => void {
if (typeof window === 'undefined') return () => {};
const handleOnline = () => callback(true);
const handleOffline = () => callback(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// Pending operations
export async function queuePlantRegistration(plantData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-plants', {
id,
data: plantData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-plants');
}
return id;
}
export async function queueTransportEvent(eventData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-transport', {
id,
data: eventData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-transport');
}
return id;
}
export async function getPendingPlants(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-plants');
}
export async function getPendingTransport(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-transport');
}
export async function removePendingPlant(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-plants', id);
}
export async function removePendingTransport(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-transport', id);
}
// Plant caching
export async function cachePlant(plant: any): Promise<void> {
const db = await getDB();
await db.put('cached-plants', {
id: plant.id,
data: plant,
cachedAt: new Date().toISOString(),
});
}
export async function getCachedPlant(id: string): Promise<any | null> {
const db = await getDB();
const cached = await db.get('cached-plants', id);
return cached?.data || null;
}
export async function getCachedPlants(): Promise<any[]> {
const db = await getDB();
const all = await db.getAll('cached-plants');
return all.map((item) => item.data);
}
export async function clearCachedPlants(): Promise<void> {
const db = await getDB();
await db.clear('cached-plants');
}
// User preferences
export async function setPreference(key: string, value: any): Promise<void> {
const db = await getDB();
await db.put('user-preferences', value, key);
}
export async function getPreference<T>(key: string): Promise<T | null> {
const db = await getDB();
return db.get('user-preferences', key) as Promise<T | null>;
}
// Sync operations
export async function syncPendingPlants(): Promise<{ success: number; failed: number }> {
const pending = await getPendingPlants();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingPlant(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncPendingTransport(): Promise<{ success: number; failed: number }> {
const pending = await getPendingTransport();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/transport/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingTransport(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncAll(): Promise<void> {
if (!isOnline()) return;
await Promise.all([
syncPendingPlants(),
syncPendingTransport(),
]);
}
// Utility
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Auto-sync when coming online
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
syncAll().catch(console.error);
});
}

268
lib/mobile/pwa.ts Normal file
View file

@ -0,0 +1,268 @@
/**
* PWA Utilities
* Service worker registration, update handling, and install prompt management
*/
export interface PWAStatus {
isInstalled: boolean;
isStandalone: boolean;
canInstall: boolean;
isOnline: boolean;
updateAvailable: boolean;
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
// Check if running as installed PWA
export function isInstalled(): boolean {
if (typeof window === 'undefined') return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
// Check if running in standalone mode
export function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(display-mode: standalone)').matches;
}
// Check if app can be installed
export function canInstall(): boolean {
return deferredPrompt !== null;
}
// Get current PWA status
export function getPWAStatus(): PWAStatus {
return {
isInstalled: isInstalled(),
isStandalone: isStandalone(),
canInstall: canInstall(),
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
updateAvailable: false, // Updated by service worker
};
}
// Prompt user to install PWA
export async function promptInstall(): Promise<boolean> {
if (!deferredPrompt) {
return false;
}
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
return outcome === 'accepted';
}
// Register service worker
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
swRegistration = registration;
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New update available
dispatchPWAEvent('updateavailable');
}
});
});
return registration;
} catch (error) {
console.error('Service worker registration failed:', error);
return null;
}
}
// Apply pending update
export async function applyUpdate(): Promise<void> {
if (!swRegistration?.waiting) return;
// Tell service worker to skip waiting
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload page after new service worker takes over
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
// Unregister service worker
export async function unregisterServiceWorker(): Promise<boolean> {
if (!swRegistration) return false;
const success = await swRegistration.unregister();
if (success) {
swRegistration = null;
}
return success;
}
// Clear all caches
export async function clearCaches(): Promise<void> {
if (typeof caches === 'undefined') return;
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
// Subscribe to push notifications
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!swRegistration) {
console.error('Service worker not registered');
return null;
}
try {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
return subscription;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
// Unsubscribe from push notifications
export async function unsubscribeFromPush(): Promise<boolean> {
if (!swRegistration) return false;
try {
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
return await subscription.unsubscribe();
}
return true;
} catch {
return false;
}
}
// Check push notification permission
export function getPushPermission(): NotificationPermission {
if (typeof Notification === 'undefined') return 'denied';
return Notification.permission;
}
// Request push notification permission
export async function requestPushPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied';
return await Notification.requestPermission();
}
// Initialize PWA
export function initPWA(): () => void {
if (typeof window === 'undefined') return () => {};
// Listen for install prompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
dispatchPWAEvent('caninstall');
};
// Listen for app installed
const handleAppInstalled = () => {
deferredPrompt = null;
dispatchPWAEvent('installed');
};
// Listen for online/offline
const handleOnline = () => dispatchPWAEvent('online');
const handleOffline = () => dispatchPWAEvent('offline');
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Register service worker
registerServiceWorker();
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// PWA event dispatcher
function dispatchPWAEvent(type: string, detail?: any): void {
window.dispatchEvent(new CustomEvent(`pwa:${type}`, { detail }));
}
// Utility to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Share API wrapper
export async function share(data: ShareData): Promise<boolean> {
if (!navigator.share) {
return false;
}
try {
await navigator.share(data);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Share failed:', error);
}
return false;
}
}
// Check if Web Share API is supported
export function canShare(data?: ShareData): boolean {
if (!navigator.share) return false;
if (!data) return true;
return navigator.canShare?.(data) ?? true;
}

View file

@ -1,4 +1,150 @@
module.exports = {
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-stylesheets',
expiration: {
maxEntries: 4,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
},
{
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-font-assets',
expiration: {
maxEntries: 4,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
},
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-image-assets',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/_next\/image\?url=.+$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'next-image',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:mp3|wav|ogg)$/i,
handler: 'CacheFirst',
options: {
rangeRequests: true,
cacheName: 'static-audio-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:mp4)$/i,
handler: 'CacheFirst',
options: {
rangeRequests: true,
cacheName: 'static-video-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:js)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-js-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:css|less)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-style-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'next-data',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/api\/.*$/i,
handler: 'NetworkFirst',
method: 'GET',
options: {
cacheName: 'apis',
expiration: {
maxEntries: 16,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
networkTimeoutSeconds: 10,
},
},
{
urlPattern: /.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'others',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
networkTimeoutSeconds: 10,
},
},
],
});
module.exports = withPWA({
swcMinify: true,
i18n: {
locales: ["en", "es"],
@ -25,4 +171,4 @@ module.exports = {
},
]
},
}
});

View file

@ -24,8 +24,10 @@
"classnames": "^2.3.1",
"drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7",
"idb": "^7.1.1",
"next": "^12.2.3",
"next-drupal": "^1.6.0",
"next-pwa": "^5.6.0",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View file

@ -6,6 +6,9 @@ import { syncDrupalPreviewRoutes } from "next-drupal"
import "nprogress/nprogress.css"
import "styles/globals.css"
import "styles/mobile.css"
import { initPWA } from "lib/mobile/pwa"
NProgress.configure({ showSpinner: false })
@ -21,6 +24,13 @@ export default function App({ Component, pageProps }) {
if (!queryClientRef.current) {
queryClientRef.current = new QueryClient()
}
// Initialize PWA
React.useEffect(() => {
const cleanup = initPWA()
return cleanup
}, [])
return (
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>

289
pages/m/index.tsx Normal file
View file

@ -0,0 +1,289 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { BottomNav, MobileHeader, InstallPrompt, PullToRefresh } from 'components/mobile';
import { isOnline, syncAll } from 'lib/mobile/offline';
interface QuickAction {
href: string;
icon: React.ReactNode;
label: string;
color: string;
}
const quickActions: QuickAction[] = [
{
href: '/m/scan',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
),
label: 'Scan QR',
color: 'bg-blue-500',
},
{
href: '/m/quick-add',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
),
label: 'Add Plant',
color: 'bg-green-500',
},
{
href: '/plants/explore',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
),
label: 'Explore',
color: 'bg-purple-500',
},
{
href: '/plants/register',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
label: 'Register',
color: 'bg-orange-500',
},
];
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
trend?: 'up' | 'down';
trendValue?: string;
}
function StatCard({ label, value, icon, trend, trendValue }: StatCardProps) {
return (
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
{trend && trendValue && (
<p className={`text-xs mt-1 ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{trend === 'up' ? '+' : '-'}{trendValue}
</p>
)}
</div>
<div className="p-2 bg-gray-50 rounded-lg">{icon}</div>
</div>
</div>
);
}
export default function MobileHome() {
const [online, setOnline] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [stats] = React.useState({
plants: 24,
tracked: 18,
carbonSaved: '12.5',
foodMiles: '156',
});
React.useEffect(() => {
setOnline(isOnline());
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const handleRefresh = async () => {
setRefreshing(true);
try {
await syncAll();
// Simulate data refresh
await new Promise((resolve) => setTimeout(resolve, 1000));
} finally {
setRefreshing(false);
}
};
return (
<>
<Head>
<title>LocalGreenChain - Mobile</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#16a34a" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</Head>
<div className="min-h-screen bg-gray-50 pb-20 md:hidden">
<MobileHeader />
{/* Offline indicator */}
{!online && (
<div className="fixed top-14 left-0 right-0 bg-amber-500 text-white text-sm text-center py-1 z-40">
You're offline. Some features may be limited.
</div>
)}
<PullToRefresh onRefresh={handleRefresh} className="min-h-screen pt-14">
<div className="p-4 space-y-6">
{/* Welcome Section */}
<section>
<h2 className="text-xl font-bold text-gray-900">Welcome back</h2>
<p className="text-gray-600 mt-1">Track your plants, save the planet.</p>
</section>
{/* Quick Actions */}
<section>
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Quick Actions</h3>
<div className="grid grid-cols-4 gap-3">
{quickActions.map((action) => (
<Link key={action.href} href={action.href}>
<a className="flex flex-col items-center">
<div className={`${action.color} text-white p-3 rounded-xl shadow-sm`}>
{action.icon}
</div>
<span className="text-xs text-gray-600 mt-2 text-center">{action.label}</span>
</a>
</Link>
))}
</div>
</section>
{/* Stats Grid */}
<section>
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Your Impact</h3>
<div className="grid grid-cols-2 gap-3">
<StatCard
label="My Plants"
value={stats.plants}
trend="up"
trendValue="3 this week"
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-600" 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>
}
/>
<StatCard
label="Tracked"
value={stats.tracked}
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
}
/>
<StatCard
label="CO2 Saved (kg)"
value={stats.carbonSaved}
trend="up"
trendValue="2.3 kg"
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<StatCard
label="Food Miles"
value={stats.foodMiles}
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
/>
</div>
</section>
{/* Recent Activity */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide">Recent Activity</h3>
<Link href="/plants/explore">
<a className="text-sm text-green-600 font-medium">View all</a>
</Link>
</div>
<div className="space-y-3">
<ActivityItem
title="Cherry Tomato planted"
description="Registered to blockchain"
time="2 hours ago"
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
}
iconBg="bg-green-100"
iconColor="text-green-600"
/>
<ActivityItem
title="Transport logged"
description="Farm to market - 12 miles"
time="Yesterday"
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
}
iconBg="bg-blue-100"
iconColor="text-blue-600"
/>
<ActivityItem
title="Basil harvested"
description="Generation 3 complete"
time="3 days ago"
icon={
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
}
iconBg="bg-purple-100"
iconColor="text-purple-600"
/>
</div>
</section>
</div>
</PullToRefresh>
<BottomNav />
<InstallPrompt />
</div>
</>
);
}
interface ActivityItemProps {
title: string;
description: string;
time: string;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}
function ActivityItem({ title, description, time, icon, iconBg, iconColor }: ActivityItemProps) {
return (
<div className="flex items-center space-x-3 bg-white p-3 rounded-xl border border-gray-100">
<div className={`${iconBg} ${iconColor} p-2 rounded-lg`}>{icon}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{title}</p>
<p className="text-xs text-gray-500">{description}</p>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap">{time}</span>
</div>
);
}

252
pages/m/profile.tsx Normal file
View file

@ -0,0 +1,252 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { MobileHeader, BottomNav } from 'components/mobile';
import { isOnline, getPendingPlants, getPendingTransport, clearCachedPlants } from 'lib/mobile/offline';
import { getPWAStatus, promptInstall, clearCaches } from 'lib/mobile/pwa';
interface ProfileStats {
plantsRegistered: number;
co2Saved: number;
foodMiles: number;
generation: number;
}
export default function ProfilePage() {
const [online, setOnline] = React.useState(true);
const [pwaStatus, setPwaStatus] = React.useState(() => getPWAStatus());
const [pendingSync, setPendingSync] = React.useState({ plants: 0, transport: 0 });
const [stats] = React.useState<ProfileStats>({
plantsRegistered: 24,
co2Saved: 12.5,
foodMiles: 156,
generation: 3,
});
React.useEffect(() => {
setOnline(isOnline());
const handleOnline = () => {
setOnline(true);
setPwaStatus(getPWAStatus());
};
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Load pending items count
const loadPending = async () => {
const plants = await getPendingPlants();
const transport = await getPendingTransport();
setPendingSync({ plants: plants.length, transport: transport.length });
};
loadPending();
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const handleInstall = async () => {
const success = await promptInstall();
if (success) {
setPwaStatus(getPWAStatus());
}
};
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached data?')) {
await clearCaches();
await clearCachedPlants();
alert('Cache cleared successfully');
}
};
return (
<>
<Head>
<title>Profile - LocalGreenChain</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
</Head>
<div className="min-h-screen bg-gray-50 pb-24 md:hidden">
<MobileHeader title="Profile" />
<div className="pt-14 p-4 space-y-6">
{/* Profile header */}
<section className="bg-white rounded-2xl p-6 shadow-sm">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Local Grower</h2>
<p className="text-sm text-gray-500">Member since 2024</p>
<div className="flex items-center mt-1">
<div className={`w-2 h-2 rounded-full ${online ? 'bg-green-500' : 'bg-amber-500'} mr-2`} />
<span className="text-xs text-gray-500">{online ? 'Online' : 'Offline'}</span>
</div>
</div>
</div>
</section>
{/* Stats */}
<section>
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Your Stats</h3>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-2xl font-bold text-gray-900">{stats.plantsRegistered}</p>
<p className="text-sm text-gray-500">Plants Registered</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-2xl font-bold text-gray-900">{stats.co2Saved} kg</p>
<p className="text-sm text-gray-500">CO2 Saved</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-2xl font-bold text-gray-900">{stats.foodMiles}</p>
<p className="text-sm text-gray-500">Food Miles Tracked</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-2xl font-bold text-gray-900">Gen {stats.generation}</p>
<p className="text-sm text-gray-500">Max Generation</p>
</div>
</div>
</section>
{/* Pending sync */}
{(pendingSync.plants > 0 || pendingSync.transport > 0) && (
<section className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start space-x-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-amber-600 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div>
<p className="font-medium text-amber-800">Pending Sync</p>
<p className="text-sm text-amber-700 mt-1">
{pendingSync.plants} plants and {pendingSync.transport} transport events will sync when you're online.
</p>
</div>
</div>
</section>
)}
{/* App settings */}
<section>
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">App Settings</h3>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{/* Install PWA */}
{!pwaStatus.isInstalled && pwaStatus.canInstall && (
<button
onClick={handleInstall}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div className="text-left">
<p className="font-medium text-gray-900">Install App</p>
<p className="text-sm text-gray-500">Add to home screen for quick access</p>
</div>
</div>
<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="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Notifications */}
<Link href="/m/settings/notifications">
<a className="flex items-center justify-between p-4 hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<div className="text-left">
<p className="font-medium text-gray-900">Notifications</p>
<p className="text-sm text-gray-500">Manage push notifications</p>
</div>
</div>
<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="M9 5l7 7-7 7" />
</svg>
</a>
</Link>
{/* Privacy */}
<Link href="/m/settings/privacy">
<a className="flex items-center justify-between p-4 hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div className="text-left">
<p className="font-medium text-gray-900">Privacy</p>
<p className="text-sm text-gray-500">Location, data sharing</p>
</div>
</div>
<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="M9 5l7 7-7 7" />
</svg>
</a>
</Link>
{/* Clear cache */}
<button
onClick={handleClearCache}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div className="text-left">
<p className="font-medium text-gray-900">Clear Cache</p>
<p className="text-sm text-gray-500">Free up storage space</p>
</div>
</div>
</button>
</div>
</section>
{/* About */}
<section>
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">About</h3>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
<div className="flex items-center justify-between p-4">
<span className="text-gray-600">Version</span>
<span className="text-gray-900">1.0.0</span>
</div>
<div className="flex items-center justify-between p-4">
<span className="text-gray-600">App Mode</span>
<span className="text-gray-900">{pwaStatus.isStandalone ? 'Standalone' : 'Browser'}</span>
</div>
<Link href="/">
<a className="flex items-center justify-between p-4 hover:bg-gray-50">
<span className="text-gray-600">Switch to Desktop</span>
<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="M9 5l7 7-7 7" />
</svg>
</a>
</Link>
</div>
</section>
</div>
<BottomNav />
</div>
</>
);
}

397
pages/m/quick-add.tsx Normal file
View file

@ -0,0 +1,397 @@
import * as React from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { MobileHeader, BottomNav } from 'components/mobile';
import { isOnline, queuePlantRegistration } from 'lib/mobile/offline';
import { getCamera, CapturedImage } from 'lib/mobile/camera';
interface QuickAddForm {
name: string;
species: string;
variety?: string;
parentId?: string;
}
const commonSpecies = [
'Tomato',
'Pepper',
'Basil',
'Lettuce',
'Cucumber',
'Spinach',
'Kale',
'Carrot',
'Radish',
'Bean',
'Pea',
'Squash',
'Other',
];
export default function QuickAddPage() {
const router = useRouter();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<QuickAddForm>();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [showCamera, setShowCamera] = React.useState(false);
const [capturedImage, setCapturedImage] = React.useState<CapturedImage | null>(null);
const [location, setLocation] = React.useState<{ lat: number; lng: number } | null>(null);
const [locationError, setLocationError] = React.useState<string | null>(null);
const [submitResult, setSubmitResult] = React.useState<{ success: boolean; message: string; offline?: boolean } | null>(null);
const videoRef = React.useRef<HTMLVideoElement>(null);
const selectedSpecies = watch('species');
// Get location on mount
React.useEffect(() => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
},
(error) => {
setLocationError(error.message);
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
}, []);
const handleCameraCapture = async () => {
const camera = getCamera();
if (showCamera && videoRef.current) {
try {
const image = await camera.capturePhoto();
setCapturedImage(image);
camera.stopCamera();
setShowCamera(false);
} catch (error) {
console.error('Failed to capture:', error);
}
} else {
setShowCamera(true);
if (videoRef.current) {
try {
await camera.startCamera(videoRef.current);
} catch (error) {
console.error('Failed to start camera:', error);
setShowCamera(false);
}
}
}
};
const onSubmit = async (data: QuickAddForm) => {
setIsSubmitting(true);
setSubmitResult(null);
const plantData = {
...data,
location,
image: capturedImage?.dataUrl,
registeredAt: new Date().toISOString(),
};
try {
if (isOnline()) {
// Online: submit directly to API
const response = await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plantData),
});
if (response.ok) {
const result = await response.json();
setSubmitResult({
success: true,
message: `${data.name} registered successfully!`,
});
setTimeout(() => {
router.push(`/plants/${result.id}`);
}, 2000);
} else {
throw new Error('Registration failed');
}
} else {
// Offline: queue for later
await queuePlantRegistration(plantData);
setSubmitResult({
success: true,
message: `${data.name} saved offline. It will sync when you're back online.`,
offline: true,
});
setTimeout(() => {
router.push('/m');
}, 2000);
}
} catch (error) {
setSubmitResult({
success: false,
message: 'Failed to register plant. Please try again.',
});
} finally {
setIsSubmitting(false);
}
};
return (
<>
<Head>
<title>Quick Add Plant - LocalGreenChain</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
</Head>
<div className="min-h-screen bg-gray-50 pb-24 md:hidden">
<MobileHeader title="Quick Add Plant" showBack />
<div className="pt-14 p-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Photo capture */}
<section>
<label className="block text-sm font-medium text-gray-700 mb-2">
Plant Photo (optional)
</label>
{showCamera ? (
<div className="relative rounded-xl overflow-hidden bg-black aspect-video">
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
<button
type="button"
onClick={handleCameraCapture}
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-16 h-16 bg-white rounded-full shadow-lg flex items-center justify-center"
>
<div className="w-12 h-12 bg-green-600 rounded-full" />
</button>
<button
type="button"
onClick={() => {
getCamera().stopCamera();
setShowCamera(false);
}}
className="absolute top-2 right-2 p-2 bg-black/50 rounded-full text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" 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>
) : capturedImage ? (
<div className="relative rounded-xl overflow-hidden aspect-video">
<img
src={capturedImage.dataUrl}
alt="Captured plant"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => setCapturedImage(null)}
className="absolute top-2 right-2 p-2 bg-black/50 rounded-full text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
) : (
<button
type="button"
onClick={handleCameraCapture}
className="w-full aspect-video bg-gray-100 border-2 border-dashed border-gray-300 rounded-xl flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm">Tap to take photo</span>
</button>
)}
</section>
{/* Plant name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Plant Name *
</label>
<input
type="text"
id="name"
{...register('name', { required: 'Name is required' })}
placeholder="e.g., My Cherry Tomato"
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
)}
</div>
{/* Species selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Species *
</label>
<div className="grid grid-cols-3 gap-2">
{commonSpecies.map((species) => (
<button
key={species}
type="button"
onClick={() => setValue('species', species)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedSpecies === species
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{species}
</button>
))}
</div>
<input type="hidden" {...register('species', { required: 'Species is required' })} />
{errors.species && (
<p className="mt-1 text-sm text-red-600">{errors.species.message}</p>
)}
</div>
{/* Variety (optional) */}
<div>
<label htmlFor="variety" className="block text-sm font-medium text-gray-700 mb-1">
Variety (optional)
</label>
<input
type="text"
id="variety"
{...register('variety')}
placeholder="e.g., Heirloom, Roma, Beefsteak"
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
</div>
{/* Parent plant (optional) */}
<div>
<label htmlFor="parentId" className="block text-sm font-medium text-gray-700 mb-1">
Parent Plant ID (optional)
</label>
<input
type="text"
id="parentId"
{...register('parentId')}
placeholder="Enter parent plant ID"
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<p className="mt-1 text-xs text-gray-500">
If this plant is a clone or seedling from another plant
</p>
</div>
{/* Location status */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center space-x-3">
{location ? (
<>
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-gray-900">Location captured</p>
<p className="text-xs text-gray-500">
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
</p>
</div>
</>
) : locationError ? (
<>
<div className="w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-gray-900">Location unavailable</p>
<p className="text-xs text-gray-500">{locationError}</p>
</div>
</>
) : (
<>
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center animate-pulse">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-gray-900">Getting location...</p>
<p className="text-xs text-gray-500">Please allow location access</p>
</div>
</>
)}
</div>
</div>
{/* Submit result */}
{submitResult && (
<div className={`rounded-lg p-4 ${submitResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
<div className="flex items-center space-x-3">
{submitResult.success ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<div>
<p className={`text-sm font-medium ${submitResult.success ? 'text-green-800' : 'text-red-800'}`}>
{submitResult.message}
</p>
{submitResult.offline && (
<p className="text-xs text-green-600 mt-1">
Will sync automatically when online
</p>
)}
</div>
</div>
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-4 bg-green-600 text-white font-semibold rounded-xl hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
>
{isSubmitting ? (
<>
<div className="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full" />
<span>Registering...</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Register Plant</span>
</>
)}
</button>
</form>
</div>
<BottomNav />
</div>
</>
);
}

225
pages/m/scan.tsx Normal file
View file

@ -0,0 +1,225 @@
import * as React from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { MobileHeader, QRScanner } from 'components/mobile';
interface ScanResult {
type: 'plant' | 'transport' | 'unknown';
id: string;
data?: any;
}
function parseScanResult(result: string): ScanResult {
// Expected formats:
// plant:{id}-{species}-{variety}
// transport:{id}
// Or just a raw ID
if (result.startsWith('plant:')) {
const parts = result.slice(6).split('-');
return {
type: 'plant',
id: parts[0],
data: {
species: parts[1],
variety: parts[2],
},
};
}
if (result.startsWith('transport:')) {
return {
type: 'transport',
id: result.slice(10),
};
}
// Assume it's a plant ID
return {
type: 'unknown',
id: result,
};
}
export default function ScanPage() {
const router = useRouter();
const [scanResult, setScanResult] = React.useState<ScanResult | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const handleScan = async (result: string) => {
setIsLoading(true);
setError(null);
try {
const parsed = parseScanResult(result);
setScanResult(parsed);
// Try to fetch the plant/transport data
if (parsed.type === 'plant' || parsed.type === 'unknown') {
const response = await fetch(`/api/plants/${parsed.id}`);
if (response.ok) {
const data = await response.json();
setScanResult({ ...parsed, type: 'plant', data });
} else if (response.status === 404) {
setError('Plant not found. Would you like to register it?');
} else {
setError('Failed to fetch plant data');
}
}
} catch (err) {
setError('An error occurred while processing the scan');
} finally {
setIsLoading(false);
}
};
const handleScanError = (err: Error) => {
setError(err.message);
};
const handleViewPlant = () => {
if (scanResult) {
router.push(`/plants/${scanResult.id}`);
}
};
const handleRegisterNew = () => {
router.push('/m/quick-add');
};
const handleRescan = () => {
setScanResult(null);
setError(null);
};
return (
<>
<Head>
<title>Scan QR Code - LocalGreenChain</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
</Head>
<div className="min-h-screen bg-black md:hidden">
<MobileHeader
title="Scan QR Code"
showBack
rightAction={
<button
className="text-sm text-green-500 font-medium"
onClick={() => router.push('/m/quick-add')}
>
Manual
</button>
}
/>
<div className="pt-14 h-screen">
{!scanResult && !error && (
<QRScanner
onScan={handleScan}
onError={handleScanError}
className="h-full"
/>
)}
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 text-center">
<div className="animate-spin w-10 h-10 border-4 border-green-500 border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-gray-600">Looking up plant...</p>
</div>
</div>
)}
{/* Error state */}
{error && !scanResult && (
<div className="absolute inset-0 bg-black flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-sm text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Scan Error</h3>
<p className="text-gray-600 mb-6">{error}</p>
<div className="flex space-x-3">
<button
onClick={handleRescan}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200"
>
Try Again
</button>
<button
onClick={handleRegisterNew}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
>
Register New
</button>
</div>
</div>
</div>
)}
{/* Success result */}
{scanResult && scanResult.data && (
<div className="absolute inset-0 bg-black flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-sm">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">Plant Found!</h3>
<p className="text-gray-500 text-sm">ID: {scanResult.id}</p>
</div>
{/* Plant details */}
<div className="mt-6 space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-500">Name</span>
<span className="font-medium">{scanResult.data.name || 'Unknown'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-500">Species</span>
<span className="font-medium">{scanResult.data.species || 'Unknown'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-500">Generation</span>
<span className="font-medium">{scanResult.data.generation || 1}</span>
</div>
{scanResult.data.registeredAt && (
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-500">Registered</span>
<span className="font-medium">
{new Date(scanResult.data.registeredAt).toLocaleDateString()}
</span>
</div>
)}
</div>
{/* Actions */}
<div className="mt-6 flex space-x-3">
<button
onClick={handleRescan}
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200"
>
Scan Another
</button>
<button
onClick={handleViewPlant}
className="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
>
View Details
</button>
</div>
</div>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect fill="#16a34a" width="192" height="192" rx="32"/>
<g transform="translate(48, 48)">
<circle cx="48" cy="48" r="40" fill="none" stroke="#fff" stroke-width="8"/>
<path d="M48 28 L48 68 M28 48 L68 48" stroke="#fff" stroke-width="8" stroke-linecap="round"/>
<circle cx="48" cy="28" r="8" fill="#22c55e"/>
<path d="M32 75 Q48 90 64 75" stroke="#fff" stroke-width="6" fill="none" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect fill="#16a34a" width="512" height="512" rx="85"/>
<g transform="translate(128, 128)">
<circle cx="128" cy="128" r="106" fill="none" stroke="#fff" stroke-width="20"/>
<path d="M128 75 L128 181 M75 128 L181 128" stroke="#fff" stroke-width="20" stroke-linecap="round"/>
<circle cx="128" cy="75" r="20" fill="#22c55e"/>
<path d="M85 200 Q128 240 171 200" stroke="#fff" stroke-width="16" fill="none" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 548 B

96
public/manifest.json Normal file
View file

@ -0,0 +1,96 @@
{
"name": "LocalGreenChain",
"short_name": "LGC",
"description": "Track your plants from seed to seed with blockchain-verified lineage",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#16a34a",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["food", "agriculture", "sustainability"],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Home Screen"
},
{
"src": "/screenshots/mobile-home.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile Home"
}
],
"shortcuts": [
{
"name": "Scan Plant",
"short_name": "Scan",
"description": "Scan a plant QR code",
"url": "/m/scan",
"icons": [{ "src": "/icons/scan-shortcut.png", "sizes": "192x192" }]
},
{
"name": "Quick Add",
"short_name": "Add",
"description": "Quickly add a new plant",
"url": "/m/quick-add",
"icons": [{ "src": "/icons/add-shortcut.png", "sizes": "192x192" }]
}
],
"related_applications": [],
"prefer_related_applications": false
}

149
public/offline.html Normal file
View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - LocalGreenChain</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.offline-container {
background: white;
border-radius: 16px;
padding: 40px;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.offline-icon {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: #f3f4f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.offline-icon svg {
width: 40px;
height: 40px;
color: #6b7280;
}
h1 {
font-size: 24px;
font-weight: 700;
color: #111827;
margin-bottom: 12px;
}
p {
color: #6b7280;
font-size: 16px;
line-height: 1.5;
margin-bottom: 24px;
}
.retry-btn {
background: #16a34a;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.retry-btn:hover {
background: #15803d;
}
.retry-btn:active {
transform: scale(0.98);
}
.cached-pages {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.cached-pages h2 {
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.cached-pages ul {
list-style: none;
}
.cached-pages li {
margin: 8px 0;
}
.cached-pages a {
color: #16a34a;
text-decoration: none;
font-size: 14px;
}
.cached-pages a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
</svg>
</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Don't worry - some features are still available offline.</p>
<button class="retry-btn" onclick="window.location.reload()">
Try Again
</button>
<div class="cached-pages">
<h2>Available Offline</h2>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/m">Mobile Dashboard</a></li>
<li><a href="/plants/explore">Explore Plants</a></li>
</ul>
</div>
</div>
<script>
// Listen for online event to auto-reload
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>

272
public/sw.js Normal file
View file

@ -0,0 +1,272 @@
// LocalGreenChain Service Worker
// Version: 1.0.0
const CACHE_NAME = 'lgc-cache-v1';
const OFFLINE_URL = '/offline.html';
// Core files to cache for offline access
const PRECACHE_ASSETS = [
'/',
'/offline.html',
'/manifest.json',
'/favicon.ico',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
// Install event - cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Cache offline page first
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
// Cache other assets
await cache.addAll(PRECACHE_ASSETS);
// Skip waiting to activate immediately
self.skipWaiting();
})()
);
});
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Claim all clients immediately
await self.clients.claim();
// Remove old caches
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})()
);
});
// Fetch event - serve from cache or network
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
// Skip API requests - always fetch from network
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request).catch(() => {
return new Response(
JSON.stringify({ error: 'Offline', offline: true }),
{
headers: { 'Content-Type': 'application/json' },
status: 503
}
);
})
);
return;
}
// For navigation requests, try network first
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
// Try network first
const networkResponse = await fetch(event.request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// If offline, try cache
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// Fallback to offline page
return caches.match(OFFLINE_URL);
}
})()
);
return;
}
// For other requests, try cache first, then network
event.respondWith(
(async () => {
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
// Return cached response and update cache in background
event.waitUntil(
(async () => {
try {
const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse);
}
} catch (e) {
// Network failed, cached version will be used
}
})()
);
return cachedResponse;
}
try {
const networkResponse = await fetch(event.request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// For images, return a placeholder
if (event.request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="#f3f4f6" width="100" height="100"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af" font-size="12">Offline</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
throw error;
}
})()
);
});
// Handle push notifications
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: data.id || 1,
url: data.url || '/'
},
actions: data.actions || [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title || 'LocalGreenChain', options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') {
return;
}
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Check if there's already a window open
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-plants') {
event.waitUntil(syncPlants());
} else if (event.tag === 'sync-transport') {
event.waitUntil(syncTransport());
}
});
async function syncPlants() {
// Get pending plant registrations from IndexedDB and sync them
try {
const pendingPlants = await getPendingFromIDB('pending-plants');
for (const plant of pendingPlants) {
await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plant)
});
await removeFromIDB('pending-plants', plant.id);
}
} catch (error) {
console.error('Plant sync failed:', error);
}
}
async function syncTransport() {
// Get pending transport events from IndexedDB and sync them
try {
const pendingEvents = await getPendingFromIDB('pending-transport');
for (const event of pendingEvents) {
await fetch('/api/transport/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
await removeFromIDB('pending-transport', event.id);
}
} catch (error) {
console.error('Transport sync failed:', error);
}
}
// IndexedDB helpers (simplified)
function getPendingFromIDB(storeName) {
return new Promise((resolve) => {
// In production, implement proper IndexedDB operations
resolve([]);
});
}
function removeFromIDB(storeName, id) {
return new Promise((resolve) => {
// In production, implement proper IndexedDB operations
resolve();
});
}

288
styles/mobile.css Normal file
View file

@ -0,0 +1,288 @@
/* Mobile-specific styles for LocalGreenChain PWA */
/* Safe area padding for notched devices */
.pt-safe {
padding-top: env(safe-area-inset-top, 0);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.pl-safe {
padding-left: env(safe-area-inset-left, 0);
}
.pr-safe {
padding-right: env(safe-area-inset-right, 0);
}
/* Scanning line animation for QR scanner */
@keyframes scan-line {
0% {
top: 0;
}
50% {
top: 100%;
}
100% {
top: 0;
}
}
.animate-scan-line {
animation: scan-line 2s ease-in-out infinite;
}
/* Slide up animation for install prompt */
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out forwards;
}
/* Touch-friendly tap targets */
.touch-target {
min-width: 44px;
min-height: 44px;
}
/* Prevent text selection on interactive elements */
.no-select {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Prevent zoom on double-tap for buttons */
button,
a,
input,
select,
textarea {
touch-action: manipulation;
}
/* Smooth scrolling with overscroll behavior */
.scroll-container {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Hide scrollbar on mobile while keeping functionality */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Pull to refresh indicator styling */
.pull-indicator {
transition: transform 0.2s ease-out;
}
/* Swipeable card styling */
.swipeable-card {
touch-action: pan-y;
will-change: transform;
}
/* Bottom navigation bar styling */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.bottom-nav {
padding-bottom: calc(env(safe-area-inset-bottom) + 0.5rem);
}
}
/* Mobile form styling improvements */
@media (max-width: 768px) {
/* Larger touch targets for form elements */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="tel"],
select,
textarea {
font-size: 16px; /* Prevents iOS zoom on focus */
padding: 0.875rem;
}
/* Full-width buttons */
.btn-mobile-full {
width: 100%;
padding: 1rem;
}
/* Sticky header/footer */
.sticky-header {
position: sticky;
top: 0;
z-index: 40;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.sticky-footer {
position: sticky;
bottom: 0;
z-index: 40;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
}
/* Dark mode support for mobile */
@media (prefers-color-scheme: dark) {
.dark-mode-aware {
background-color: #1f2937;
color: #f9fafb;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-scan-line,
.animate-slide-up,
.animate-spin,
.animate-pulse {
animation: none !important;
}
.transition-all,
.transition-transform,
.transition-opacity {
transition: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.border-gray-100 {
border-color: #6b7280;
}
.text-gray-500 {
color: #374151;
}
}
/* Standalone PWA specific styles */
@media (display-mode: standalone) {
/* Prevent overscroll on the body */
body {
overscroll-behavior: none;
}
/* Adjust padding for status bar */
.standalone-header {
padding-top: calc(env(safe-area-inset-top) + 0.5rem);
}
}
/* Haptic feedback visual indicator */
.haptic-feedback:active {
opacity: 0.7;
transform: scale(0.98);
}
/* Camera overlay styling */
.camera-overlay {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.6) 0%,
transparent 20%,
transparent 80%,
rgba(0, 0, 0, 0.6) 100%
);
}
/* QR scanner corner styling */
.scanner-corner {
width: 32px;
height: 32px;
border-width: 4px;
border-color: #16a34a;
}
/* Loading skeleton for mobile */
.skeleton-mobile {
background: linear-gradient(
90deg,
#f3f4f6 0%,
#e5e7eb 50%,
#f3f4f6 100%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Toast notification styling */
.toast-mobile {
position: fixed;
bottom: calc(env(safe-area-inset-bottom) + 80px);
left: 16px;
right: 16px;
z-index: 100;
}
/* Floating action button styling */
.fab {
position: fixed;
bottom: calc(env(safe-area-inset-bottom) + 80px);
right: 16px;
width: 56px;
height: 56px;
border-radius: 28px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 30;
}
/* Card swipe actions */
.swipe-action-left {
background: linear-gradient(to left, #ef4444, #dc2626);
}
.swipe-action-right {
background: linear-gradient(to right, #22c55e, #16a34a);
}
/* Mobile-optimized image aspect ratios */
.aspect-plant-photo {
aspect-ratio: 4 / 3;
}
.aspect-plant-card {
aspect-ratio: 3 / 4;
}
/* Optimized font rendering for mobile */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}