This implements the mobile optimization agent (P3 - Enhancement) with: PWA Configuration: - Add next-pwa integration with offline caching strategies - Create web app manifest for installability - Add service worker with background sync support - Create offline fallback page Mobile Components: - BottomNav: Touch-friendly bottom navigation bar - MobileHeader: Responsive header with back navigation - InstallPrompt: Smart PWA install prompt (iOS & Android) - SwipeableCard: Gesture-based swipeable cards - PullToRefresh: Native-like pull to refresh - QRScanner: Camera-based QR code scanning Mobile Library: - camera.ts: Camera access and photo capture utilities - offline.ts: IndexedDB-based offline storage and sync - gestures.ts: Touch gesture detection (swipe, pinch, tap) - pwa.ts: PWA status, install prompts, service worker management Mobile Pages: - /m: Mobile dashboard with quick actions and stats - /m/scan: QR code scanner for plant lookup - /m/quick-add: Streamlined plant registration form - /m/profile: User profile with offline status Dependencies added: next-pwa, idb
225 lines
8 KiB
TypeScript
225 lines
8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|