Merge: Mobile Optimization with PWA (Agent 10) - resolved conflicts, kept comprehensive SW
This commit is contained in:
commit
a7dba0fc9b
24 changed files with 3779 additions and 2 deletions
92
components/mobile/BottomNav.tsx
Normal file
92
components/mobile/BottomNav.tsx
Normal 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;
|
||||
183
components/mobile/InstallPrompt.tsx
Normal file
183
components/mobile/InstallPrompt.tsx
Normal 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;
|
||||
101
components/mobile/MobileHeader.tsx
Normal file
101
components/mobile/MobileHeader.tsx
Normal 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;
|
||||
138
components/mobile/PullToRefresh.tsx
Normal file
138
components/mobile/PullToRefresh.tsx
Normal 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;
|
||||
196
components/mobile/QRScanner.tsx
Normal file
196
components/mobile/QRScanner.tsx
Normal 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;
|
||||
131
components/mobile/SwipeableCard.tsx
Normal file
131
components/mobile/SwipeableCard.tsx
Normal 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;
|
||||
6
components/mobile/index.ts
Normal file
6
components/mobile/index.ts
Normal 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
254
lib/mobile/camera.ts
Normal 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
257
lib/mobile/gestures.ts
Normal 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
4
lib/mobile/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './camera';
|
||||
export * from './offline';
|
||||
export * from './gestures';
|
||||
export * from './pwa';
|
||||
275
lib/mobile/offline.ts
Normal file
275
lib/mobile/offline.ts
Normal 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
268
lib/mobile/pwa.ts
Normal 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;
|
||||
}
|
||||
150
next.config.js
150
next.config.js
|
|
@ -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 = {
|
|||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"drupal-jsonapi-params": "^1.2.2",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"idb": "^7.1.1",
|
||||
"multer": "^2.0.2",
|
||||
"next": "^12.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-drupal": "^1.6.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,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 })
|
||||
|
||||
|
|
@ -22,6 +25,13 @@ export default function App({ Component, pageProps: { session, ...pageProps } })
|
|||
if (!queryClientRef.current) {
|
||||
queryClientRef.current = new QueryClient()
|
||||
}
|
||||
|
||||
// Initialize PWA
|
||||
React.useEffect(() => {
|
||||
const cleanup = initPWA()
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
|
|
|
|||
289
pages/m/index.tsx
Normal file
289
pages/m/index.tsx
Normal 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
252
pages/m/profile.tsx
Normal 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
397
pages/m/quick-add.tsx
Normal 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
225
pages/m/scan.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
public/icons/icon-192x192.svg
Normal file
9
public/icons/icon-192x192.svg
Normal 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 |
9
public/icons/icon-512x512.svg
Normal file
9
public/icons/icon-512x512.svg
Normal 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
96
public/manifest.json
Normal 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
149
public/offline.html
Normal 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>
|
||||
288
styles/mobile.css
Normal file
288
styles/mobile.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue