localgreenchain/pages/m/quick-add.tsx
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
This implements the mobile optimization agent (P3 - Enhancement) with:

PWA Configuration:
- Add next-pwa integration with offline caching strategies
- Create web app manifest for installability
- Add service worker with background sync support
- Create offline fallback page

Mobile Components:
- BottomNav: Touch-friendly bottom navigation bar
- MobileHeader: Responsive header with back navigation
- InstallPrompt: Smart PWA install prompt (iOS & Android)
- SwipeableCard: Gesture-based swipeable cards
- PullToRefresh: Native-like pull to refresh
- QRScanner: Camera-based QR code scanning

Mobile Library:
- camera.ts: Camera access and photo capture utilities
- offline.ts: IndexedDB-based offline storage and sync
- gestures.ts: Touch gesture detection (swipe, pinch, tap)
- pwa.ts: PWA status, install prompts, service worker management

Mobile Pages:
- /m: Mobile dashboard with quick actions and stats
- /m/scan: QR code scanner for plant lookup
- /m/quick-add: Streamlined plant registration form
- /m/profile: User profile with offline status

Dependencies added: next-pwa, idb
2025-11-23 03:56:30 +00:00

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>
</>
);
}