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
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|