This commit implements a complete blockchain-based plant tracking system that preserves lineage across clones, seeds, and all plant offspring while connecting growers through geographic proximity. Features implemented: - Custom blockchain with proof-of-work consensus - Plant registration and cloning with lineage tracking - Geographic discovery to find nearby plants and growers - Integration with plants.net API for plant identification - Comprehensive web UI for plant management - RESTful API endpoints for all operations - Network statistics and visualization Core Components: - lib/blockchain/: PlantBlock, PlantChain, and blockchain manager - lib/services/: plants.net API and geolocation services - pages/api/plants/: REST API endpoints for all operations - pages/: Frontend UI pages for registration, exploration, and lineage Technical Details: - TypeScript for type safety - Next.js for server-side rendering - Tailwind CSS for responsive design - JSON file-based blockchain storage - Haversine distance calculations for geolocation - OpenStreetMap integration for geocoding This system enables large-scale adoption by: - Making plant lineage tracking accessible to everyone - Connecting local communities through plant sharing - Providing immutable proof of plant provenance - Supporting unlimited generations of plant propagation - Scaling from individual growers to global networks Documentation includes comprehensive README with: - Quick start guide - API reference - Architecture details - Scaling recommendations - Use cases for various audiences - Roadmap for future enhancements
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import Link from 'next/link';
|
|
import Head from 'next/head';
|
|
|
|
interface Plant {
|
|
id: string;
|
|
commonName: string;
|
|
scientificName?: string;
|
|
owner: { name: string };
|
|
location: { city?: string; country?: string };
|
|
status: string;
|
|
generation: number;
|
|
}
|
|
|
|
interface NearbyPlant {
|
|
plant: Plant;
|
|
distance: number;
|
|
}
|
|
|
|
export default function ExplorePlants() {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searchResults, setSearchResults] = useState<Plant[]>([]);
|
|
const [nearbyPlants, setNearbyPlants] = useState<NearbyPlant[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [latitude, setLatitude] = useState('');
|
|
const [longitude, setLongitude] = useState('');
|
|
const [radius, setRadius] = useState('50');
|
|
const [activeTab, setActiveTab] = useState<'search' | 'nearby'>('search');
|
|
|
|
const handleSearch = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/plants/search?q=${encodeURIComponent(searchTerm)}`
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setSearchResults(data.results);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error searching plants:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleFindNearby = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/plants/nearby?lat=${latitude}&lon=${longitude}&radius=${radius}`
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setNearbyPlants(data.plants);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error finding nearby plants:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getCurrentLocation = () => {
|
|
if (navigator.geolocation) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
setLatitude(position.coords.latitude.toString());
|
|
setLongitude(position.coords.longitude.toString());
|
|
},
|
|
(error) => {
|
|
console.error('Error getting location:', error);
|
|
}
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
|
<Head>
|
|
<title>Explore Plants - LocalGreenChain</title>
|
|
</Head>
|
|
|
|
{/* Header */}
|
|
<header className="bg-white shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between">
|
|
<Link href="/">
|
|
<a className="text-2xl font-bold text-green-800">
|
|
🌱 LocalGreenChain
|
|
</a>
|
|
</Link>
|
|
<nav className="flex gap-4">
|
|
<Link href="/plants/register">
|
|
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
|
Register Plant
|
|
</a>
|
|
</Link>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
|
Explore the Plant Network
|
|
</h1>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-4 mb-6">
|
|
<button
|
|
onClick={() => setActiveTab('search')}
|
|
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
|
activeTab === 'search'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
🔍 Search Plants
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('nearby')}
|
|
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
|
activeTab === 'nearby'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
📍 Find Nearby
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search Tab */}
|
|
{activeTab === 'search' && (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<form onSubmit={handleSearch} className="mb-6">
|
|
<div className="flex gap-4">
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search by plant name, species, or owner..."
|
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="px-8 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
|
>
|
|
{loading ? 'Searching...' : 'Search'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{searchResults.length > 0 && (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
Found {searchResults.length} plants
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{searchResults.map((plant) => (
|
|
<PlantCard key={plant.id} plant={plant} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{searchResults.length === 0 && searchTerm && !loading && (
|
|
<p className="text-center text-gray-600 py-8">
|
|
No plants found. Try a different search term.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Nearby Tab */}
|
|
{activeTab === 'nearby' && (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<form onSubmit={handleFindNearby} className="mb-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Latitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={latitude}
|
|
onChange={(e) => setLatitude(e.target.value)}
|
|
required
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Longitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={longitude}
|
|
onChange={(e) => setLongitude(e.target.value)}
|
|
required
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Radius (km)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={radius}
|
|
onChange={(e) => setRadius(e.target.value)}
|
|
required
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
type="button"
|
|
onClick={getCurrentLocation}
|
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
|
>
|
|
📍 Use My Location
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="px-8 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
|
>
|
|
{loading ? 'Finding...' : 'Find Nearby Plants'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{nearbyPlants.length > 0 && (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
Found {nearbyPlants.length} nearby plants
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{nearbyPlants.map(({ plant, distance }) => (
|
|
<PlantCard
|
|
key={plant.id}
|
|
plant={plant}
|
|
distance={distance}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{nearbyPlants.length === 0 && latitude && longitude && !loading && (
|
|
<p className="text-center text-gray-600 py-8">
|
|
No plants found nearby. Try increasing the search radius.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlantCard({
|
|
plant,
|
|
distance,
|
|
}: {
|
|
plant: Plant;
|
|
distance?: number;
|
|
}) {
|
|
const statusColors: { [key: string]: string } = {
|
|
sprouted: 'bg-yellow-100 text-yellow-800',
|
|
growing: 'bg-green-100 text-green-800',
|
|
mature: 'bg-blue-100 text-blue-800',
|
|
flowering: 'bg-purple-100 text-purple-800',
|
|
fruiting: 'bg-orange-100 text-orange-800',
|
|
dormant: 'bg-gray-100 text-gray-800',
|
|
};
|
|
|
|
return (
|
|
<Link href={`/plants/${plant.id}`}>
|
|
<a className="block bg-gray-50 rounded-lg p-4 hover:shadow-lg transition border border-gray-200">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<h3 className="text-lg font-bold text-gray-900">
|
|
{plant.commonName}
|
|
</h3>
|
|
<span
|
|
className={`px-2 py-1 rounded-full text-xs font-semibold ${
|
|
statusColors[plant.status] || 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{plant.status}
|
|
</span>
|
|
</div>
|
|
|
|
{plant.scientificName && (
|
|
<p className="text-sm italic text-gray-600 mb-2">
|
|
{plant.scientificName}
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-1 text-sm text-gray-600">
|
|
<p>👤 {plant.owner.name}</p>
|
|
{(plant.location.city || plant.location.country) && (
|
|
<p>
|
|
📍{' '}
|
|
{[plant.location.city, plant.location.country]
|
|
.filter(Boolean)
|
|
.join(', ')}
|
|
</p>
|
|
)}
|
|
<p>🌱 Generation {plant.generation}</p>
|
|
{distance !== undefined && (
|
|
<p className="font-semibold text-green-600">
|
|
📏 {distance.toFixed(1)} km away
|
|
</p>
|
|
)}
|
|
</div>
|
|
</a>
|
|
</Link>
|
|
);
|
|
}
|