Merge pull request #1 from vespo92/claude/plant-cloning-blockchain-0183C7S2LExNj47z9yiV98NR
Blockchain system for plant cloning
This commit is contained in:
commit
24569757f2
43 changed files with 8406 additions and 12 deletions
22
.env.example
22
.env.example
|
|
@ -1,3 +1,25 @@
|
|||
# LocalGreenChain Environment Variables
|
||||
|
||||
# Plants.net API (optional)
|
||||
PLANTS_NET_API_KEY=your_api_key_here
|
||||
|
||||
# Tor Configuration
|
||||
TOR_ENABLED=false
|
||||
TOR_SOCKS_HOST=127.0.0.1
|
||||
TOR_SOCKS_PORT=9050
|
||||
TOR_CONTROL_PORT=9051
|
||||
TOR_HIDDEN_SERVICE_DIR=/var/lib/tor/localgreenchain
|
||||
|
||||
# Privacy Settings
|
||||
DEFAULT_PRIVACY_MODE=standard
|
||||
ALLOW_ANONYMOUS_REGISTRATION=true
|
||||
LOCATION_OBFUSCATION_DEFAULT=fuzzy
|
||||
|
||||
# Application Settings
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Legacy Drupal Settings (for backward compatibility)
|
||||
NEXT_PUBLIC_DRUPAL_BASE_URL=http://localhost:8080
|
||||
NEXT_IMAGE_DOMAIN=localhost
|
||||
DRUPAL_CLIENT_ID=52ce1a10-bf5c-4c81-8edf-eea3af95da84
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ npm-debug.log*
|
|||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
bun-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
|
|
|||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Dockerfile for LocalGreenChain
|
||||
# Uses Bun for fast builds and runtime
|
||||
|
||||
FROM oven/bun:1 as base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Build Next.js application
|
||||
RUN bun run build
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies and build output
|
||||
COPY --from=base /app/node_modules ./node_modules
|
||||
COPY --from=base /app/.next ./.next
|
||||
COPY --from=base /app/public ./public
|
||||
COPY --from=base /app/package.json ./package.json
|
||||
COPY --from=base /app/next.config.js ./next.config.js
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Run the application
|
||||
CMD ["bun", "run", "start"]
|
||||
437
ENVIRONMENTAL_TRACKING.md
Normal file
437
ENVIRONMENTAL_TRACKING.md
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
# 🌍 Environmental Tracking Guide
|
||||
|
||||
**Track soil, climate, nutrients, and growing conditions to optimize plant health and learn from successful growers.**
|
||||
|
||||
## Overview
|
||||
|
||||
LocalGreenChain's environmental tracking system allows you to record detailed information about where and how your plants are grown. This data helps you:
|
||||
|
||||
- **Understand** what conditions make plants thrive
|
||||
- **Compare** your setup with successful growers
|
||||
- **Optimize** growing conditions based on data
|
||||
- **Learn** from the collective experience
|
||||
- **Replicate** successful growing environments
|
||||
- **Troubleshoot** plant health issues
|
||||
|
||||
## What Can You Track?
|
||||
|
||||
### 🌱 Soil Composition
|
||||
- **Soil type**: Clay, sand, silt, loam, peat, chalk, or custom
|
||||
- **pH level**: 0-14 scale (most plants prefer 6.0-7.5)
|
||||
- **Texture**: Heavy, medium, or light
|
||||
- **Drainage**: Poor, moderate, good, or excellent
|
||||
- **Organic matter**: Percentage (0-100%)
|
||||
- **Composition**: Clay/sand/silt percentages
|
||||
- **Amendments**: Compost, manure, perlite, vermiculite, etc.
|
||||
|
||||
**Why it matters**: Soil is the foundation of plant health. pH affects nutrient availability, drainage prevents root rot, and organic matter improves structure.
|
||||
|
||||
### 🧪 Nutrients & Fertilization
|
||||
- **NPK values**: Nitrogen, Phosphorus, Potassium percentages
|
||||
- **Secondary nutrients**: Calcium, magnesium, sulfur
|
||||
- **Micronutrients**: Iron, manganese, zinc, copper, boron, molybdenum
|
||||
- **EC/TDS**: Electrical conductivity and total dissolved solids
|
||||
- **Fertilizer schedule**: Type, amount, frequency, NPK ratios
|
||||
- **Application history**: Track what you've added and when
|
||||
|
||||
**Why it matters**: Proper nutrition is crucial for growth. Too little causes deficiencies, too much can burn plants. Track what works!
|
||||
|
||||
### ☀️ Lighting Conditions
|
||||
- **Light type**: Natural, artificial, or mixed
|
||||
- **Natural light**:
|
||||
- Exposure: Full sun, partial sun, partial shade, full shade
|
||||
- Hours per day: 0-24
|
||||
- Direction: North, south, east, west
|
||||
- Quality: Direct, filtered, dappled, indirect
|
||||
- **Artificial light**:
|
||||
- Type: LED, fluorescent, HPS, MH, etc.
|
||||
- Spectrum: Full spectrum, color temperature (K)
|
||||
- Wattage and distance from plant
|
||||
- Hours per day
|
||||
- **Advanced metrics**: PPFD (μmol/m²/s), DLI (mol/m²/day)
|
||||
|
||||
**Why it matters**: Light is energy. Insufficient light causes leggy growth, too much can burn leaves. Track optimal levels for your species.
|
||||
|
||||
### 🌡️ Climate Conditions
|
||||
- **Temperature**: Day/night temps, min/max ranges (Celsius)
|
||||
- **Humidity**: Average, min, max percentages
|
||||
- **Air circulation**: None, minimal, moderate, strong
|
||||
- **Ventilation**: Poor, adequate, good, excellent
|
||||
- **CO2 levels**: PPM (ambient ~400, enhanced ~1200-1500)
|
||||
- **Season**: Spring, summer, fall, winter
|
||||
- **Hardiness zone**: USDA zones (e.g., "9b", "10a")
|
||||
|
||||
**Why it matters**: Plants have optimal temperature and humidity ranges. Extremes cause stress or death. Good airflow prevents disease.
|
||||
|
||||
### 📍 Growing Location
|
||||
- **Location type**: Indoor, outdoor, greenhouse, polytunnel, shade house, window, balcony
|
||||
- **Indoor details**: Room type (bedroom, basement, grow tent)
|
||||
- **Outdoor details**:
|
||||
- Exposure to elements: Protected, semi-protected, exposed
|
||||
- Elevation: Meters above sea level
|
||||
- Slope: Flat, gentle, moderate, steep
|
||||
- Aspect: Slope direction (affects sun exposure)
|
||||
|
||||
**Why it matters**: Location affects everything - temperature, humidity, light, pests. Indoor vs outdoor requires different care.
|
||||
|
||||
### 🪴 Container Information
|
||||
- **Type**: Pot, raised bed, ground, hydroponic, aeroponic, fabric pot, etc.
|
||||
- **Material**: Plastic, terracotta, ceramic, fabric, wood, metal
|
||||
- **Size**: Gallons, diameter, or dimensions
|
||||
- **Volume**: Liters
|
||||
- **Depth**: Centimeters
|
||||
- **Drainage**: Yes/no, number of holes
|
||||
|
||||
**Why it matters**: Container size affects root development and watering frequency. Drainage is critical - no drainage = dead plant.
|
||||
|
||||
### 💧 Watering Schedule
|
||||
- **Method**: Hand water, drip, soaker hose, sprinkler, self-watering, hydroponic
|
||||
- **Frequency**: Daily, every 2-3 days, weekly, etc.
|
||||
- **Amount**: Liters, "until runoff", inches
|
||||
- **Water source**: Tap, well, rain, filtered, distilled, RO
|
||||
- **Water quality**:
|
||||
- pH: Affects nutrient availability
|
||||
- TDS: Total dissolved solids (PPM)
|
||||
- Chlorine: Yes, no, or filtered
|
||||
|
||||
**Why it matters**: Over/underwatering kills more plants than anything. Water quality affects long-term soil health.
|
||||
|
||||
### 🌿 Surrounding Environment
|
||||
- **Companion plants**: Other species growing nearby
|
||||
- **Nearby features**: Trees, structures, walls, fences
|
||||
- **Ground cover**: Mulch, grass, bare soil, gravel
|
||||
- **Wildlife**:
|
||||
- Pollinators: Bees, butterflies, hummingbirds
|
||||
- Beneficial insects: Ladybugs, lacewings
|
||||
- Pests: Name, severity, treatment, date observed
|
||||
- Diseases: Name, symptoms, severity, treatment
|
||||
- **Microclimate factors**:
|
||||
- Wind exposure: Sheltered, moderate, exposed, windy
|
||||
- Frost pockets: Yes/no
|
||||
- Heat traps: Yes/no
|
||||
- **Ecosystem type**: Urban, suburban, rural, forest, desert, coastal, mountain, tropical
|
||||
|
||||
**Why it matters**: Companion planting improves growth. Pest/disease tracking helps prevention. Microclimates explain unexpected results.
|
||||
|
||||
### 📈 Growth Metrics
|
||||
- **Measurements**: Height, width, leaf count, flower count, fruit count
|
||||
- **Health score**: 0-100 rating
|
||||
- **Vigor**: Poor, fair, good, excellent
|
||||
- **Tracking**: Photos and notes over time
|
||||
|
||||
**Why it matters**: Measure growth to evaluate if conditions are optimal. Data drives improvement.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Recommendations
|
||||
```http
|
||||
GET /api/environment/recommendations?plantId=xyz
|
||||
```
|
||||
|
||||
Returns personalized recommendations based on your plant's environment:
|
||||
- **Critical issues**: Must fix immediately (no drainage, extreme temps)
|
||||
- **High priority**: Important for health (pH problems, insufficient light)
|
||||
- **Medium priority**: Optimization opportunities (humidity, amendments)
|
||||
- **Low priority**: Minor improvements (water quality tweaks)
|
||||
- **Environmental health score**: 0-100 overall rating
|
||||
|
||||
**Example response**:
|
||||
```json
|
||||
{
|
||||
"environmentalHealth": 75,
|
||||
"recommendations": [
|
||||
{
|
||||
"category": "soil",
|
||||
"priority": "high",
|
||||
"issue": "Low soil pH (5.2)",
|
||||
"recommendation": "Add lime to raise pH. Most plants prefer 6.0-7.0",
|
||||
"impact": "Acidic soil can lock out nutrients and harm roots"
|
||||
},
|
||||
{
|
||||
"category": "climate",
|
||||
"priority": "medium",
|
||||
"issue": "Low humidity (25%)",
|
||||
"recommendation": "Mist plants, use humidity tray, or group plants together",
|
||||
"impact": "Low humidity can cause leaf tip browning and stress"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Find Similar Environments
|
||||
```http
|
||||
GET /api/environment/similar?plantId=xyz&minScore=70
|
||||
```
|
||||
|
||||
Finds other plants growing in similar conditions:
|
||||
- Compare your environment with successful growers
|
||||
- Learn from plants that thrive in similar setups
|
||||
- Get ideas for what works in your conditions
|
||||
|
||||
**Similarity factors**:
|
||||
- Location type (indoor/outdoor/greenhouse)
|
||||
- Soil type and pH
|
||||
- Temperature and humidity ranges
|
||||
- Lighting conditions
|
||||
- Water source and quality
|
||||
- Container type
|
||||
|
||||
**Scores**:
|
||||
- **90-100**: Nearly identical conditions
|
||||
- **75-89**: Very similar - expect similar results
|
||||
- **60-74**: Moderately similar
|
||||
- **40-59**: Some similarities
|
||||
- **0-39**: Very different
|
||||
|
||||
### Compare Two Plants
|
||||
```http
|
||||
GET /api/environment/compare?plant1=xyz&plant2=abc
|
||||
```
|
||||
|
||||
Direct comparison of two specific plants:
|
||||
- See exactly what's similar and different
|
||||
- Learn why one plant might be thriving while another struggles
|
||||
- Identify which factors to adjust
|
||||
|
||||
### Growth Correlation Analysis
|
||||
```http
|
||||
GET /api/environment/analysis?species=tomato
|
||||
```
|
||||
|
||||
Network-wide analysis to identify optimal conditions:
|
||||
- What pH do successful plants average?
|
||||
- What temperature range works best?
|
||||
- Which lighting type has best results?
|
||||
- Success rates by location type, soil type, etc.
|
||||
|
||||
**Filter by species** to get species-specific insights!
|
||||
|
||||
## How Recommendations Work
|
||||
|
||||
The system analyzes your environment and generates priority-based recommendations:
|
||||
|
||||
### Soil Recommendations
|
||||
- **pH < 5.5**: Add lime (priority: high)
|
||||
- **pH > 7.5**: Add sulfur (priority: high)
|
||||
- **Organic matter < 3%**: Add compost (priority: medium)
|
||||
- **Poor drainage**: Add perlite/sand or raise beds (priority: high)
|
||||
|
||||
### Climate Recommendations
|
||||
- **Temp > 35°C**: Provide shade, increase watering (priority: high)
|
||||
- **Temp < 10°C**: Frost protection needed (priority: high)
|
||||
- **Humidity < 30%**: Mist, use humidity trays (priority: medium)
|
||||
- **Humidity > 80%**: Improve airflow (priority: medium)
|
||||
|
||||
### Light Recommendations
|
||||
- **< 4 hours/day**: Move to brighter spot or add grow lights (priority: high)
|
||||
- **Natural light direction**: Optimize plant placement
|
||||
|
||||
### Nutrient Recommendations
|
||||
- **Low NPK**: Apply balanced fertilizer (priority: medium)
|
||||
- **High EC/TDS**: Reduce feeding or flush (priority: medium)
|
||||
|
||||
### Water Recommendations
|
||||
- **pH > 7.5**: Consider pH adjustment or rainwater (priority: low)
|
||||
- **Chlorinated**: Let sit 24h or filter (priority: low)
|
||||
|
||||
### Container Recommendations
|
||||
- **No drainage**: Add holes IMMEDIATELY (priority: CRITICAL)
|
||||
- **Too small**: Repot to larger container (priority: medium)
|
||||
|
||||
## Environmental Health Scoring
|
||||
|
||||
The system calculates a 0-100 health score based on:
|
||||
|
||||
**Perfect score (100)**: Everything optimal
|
||||
- Soil pH 6.0-7.0
|
||||
- Good drainage
|
||||
- Organic matter > 3%
|
||||
- Temps 15-30°C
|
||||
- Humidity 40-70%
|
||||
- Adequate light (6+ hours or proper artificial)
|
||||
- Proper container with drainage
|
||||
- Good water quality
|
||||
|
||||
**Deductions**:
|
||||
- **-15 points**: No container drainage (CRITICAL)
|
||||
- **-15 points**: Insufficient light (< 4 hours)
|
||||
- **-10 points**: Extreme pH (< 5.5 or > 7.5)
|
||||
- **-10 points**: Extreme temperature (< 10°C or > 35°C)
|
||||
- **-10 points**: Poor soil drainage
|
||||
- **-5 points**: Low organic matter
|
||||
- **-5 points**: Poor humidity (< 30% or > 80%)
|
||||
- **-5 points**: No airflow
|
||||
- **-5 points**: Chlorinated water
|
||||
- **-5 points**: High water pH
|
||||
|
||||
**Score interpretation**:
|
||||
- **90-100**: Excellent conditions
|
||||
- **75-89**: Good conditions, minor improvements possible
|
||||
- **60-74**: Adequate, several opportunities for optimization
|
||||
- **40-59**: Suboptimal, multiple issues to address
|
||||
- **0-39**: Poor conditions, plant likely struggling
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Track Environmental Data
|
||||
|
||||
**Always track**:
|
||||
- When registering a new plant
|
||||
- After making changes (repotting, moving, fertilizing)
|
||||
- When troubleshooting problems
|
||||
- Before/after cloning (compare parent and clone environments)
|
||||
|
||||
**Update regularly**:
|
||||
- Monthly for stable setups
|
||||
- Weekly during active growth
|
||||
- Daily when troubleshooting or experimenting
|
||||
|
||||
### What to Measure
|
||||
|
||||
**Essentials** (track these at minimum):
|
||||
- Soil type and pH
|
||||
- Location type (indoor/outdoor)
|
||||
- Light exposure (hours/day)
|
||||
- Temperature range
|
||||
- Watering frequency
|
||||
|
||||
**Recommended** (for better insights):
|
||||
- Soil drainage and amendments
|
||||
- Humidity
|
||||
- Container details
|
||||
- Nutrient applications
|
||||
- Growth measurements
|
||||
|
||||
**Advanced** (for serious growers):
|
||||
- Full NPK and micronutrients
|
||||
- EC/TDS measurements
|
||||
- PPFD/DLI for lighting
|
||||
- CO2 levels
|
||||
- Pest/disease tracking
|
||||
- Companion plants
|
||||
|
||||
### Tips for Accurate Tracking
|
||||
|
||||
1. **Measure, don't guess**: Use pH meters, thermometers, soil probes
|
||||
2. **Track over time**: Conditions change - update seasonally
|
||||
3. **Note anomalies**: Heatwaves, cold snaps, unusual events
|
||||
4. **Photo documentation**: Pictures reveal patterns you might miss
|
||||
5. **Consistent timing**: Measure at the same time of day
|
||||
6. **Calibrate instruments**: Test meters regularly
|
||||
7. **Record failures too**: Learning what doesn't work is valuable
|
||||
|
||||
## Use Cases
|
||||
|
||||
### For Home Growers
|
||||
"My tomato plant is struggling. Let me check recommendations..."
|
||||
- System identifies low soil pH and insufficient light
|
||||
- Suggests adding lime and moving to sunnier spot
|
||||
- Compares with successful tomato growers nearby
|
||||
- Shows that similar plants thrive with 8+ hours sun and pH 6.5
|
||||
|
||||
### For Propagation
|
||||
"I want to clone my best basil plant..."
|
||||
- Records current environment that's producing healthy growth
|
||||
- When giving clone to friend, shares environmental data
|
||||
- Friend replicates conditions for best success
|
||||
- Tracks if clone thrives in new environment
|
||||
|
||||
### For Troubleshooting
|
||||
"Why are my pepper leaves yellowing?"
|
||||
- Checks pH: 7.8 (too high)
|
||||
- System recommends sulfur to lower pH
|
||||
- Compares with healthy pepper plants: pH 6.0-6.5
|
||||
- Adds sulfur, yellowing stops within 2 weeks
|
||||
|
||||
### For Learning
|
||||
"What conditions work best for lavender?"
|
||||
- Searches network for successful lavender plants
|
||||
- Finds they all have: pH 6.5-7.5, full sun, excellent drainage
|
||||
- Notices growers with poor drainage have struggling plants
|
||||
- Adjusts setup before planting
|
||||
|
||||
### For Research
|
||||
"Does companion planting really work?"
|
||||
- Analyzes all plants with tomato-basil companions
|
||||
- Compares health scores with lone tomatoes
|
||||
- Statistical analysis shows 15% better vigor
|
||||
- Data supports traditional growing wisdom
|
||||
|
||||
## Privacy Considerations
|
||||
|
||||
Environmental data can be:
|
||||
- **Shared openly**: Help others learn from your setup
|
||||
- **Kept private**: Track for yourself without broadcasting
|
||||
- **Anonymous**: Share conditions without revealing location/identity
|
||||
|
||||
Use privacy settings to control:
|
||||
- Whether environment data is visible to others
|
||||
- Whether it appears in similarity searches
|
||||
- Whether it's included in analysis
|
||||
- Location obfuscation (see TOR_SETUP.md)
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Automated Recommendations
|
||||
As you add more plants:
|
||||
- System learns what works in YOUR specific conditions
|
||||
- Personalized recommendations based on your history
|
||||
- "You've had success with these pH levels..."
|
||||
|
||||
### Seasonal Tracking
|
||||
- Record how conditions change through the year
|
||||
- Plan planting schedules based on historical data
|
||||
- Anticipate challenges (summer heat, winter cold)
|
||||
|
||||
### Clone Comparison
|
||||
- Automatically compare clone environments with parent
|
||||
- See if environmental differences affect growth
|
||||
- Optimize clone success rates
|
||||
|
||||
### Community Insights
|
||||
- "Plants in your area typically use X soil type"
|
||||
- "Growers in similar climates report best results with Y"
|
||||
- "This species thrives in conditions like yours"
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Coming soon:
|
||||
- **Photo-based tracking**: Upload photos, AI analyzes plant health
|
||||
- **Sensor integration**: Automatic data from soil sensors, weather stations
|
||||
- **Mobile app**: Track on-the-go with smartphone
|
||||
- **Notifications**: "Time to water", "pH out of range"
|
||||
- **Marketplace integration**: Buy recommended products
|
||||
- **Expert consultations**: Connect with master growers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"My recommendations don't make sense"**
|
||||
- Check that measurements are accurate
|
||||
- Ensure units are correct (Celsius not Fahrenheit)
|
||||
- Verify pH probe is calibrated
|
||||
- Update environmental data if conditions changed
|
||||
|
||||
**"No similar plants found"**
|
||||
- Lower the minimum similarity score
|
||||
- Your setup might be unique!
|
||||
- Add more environmental details for better matching
|
||||
- Check that other plants have environmental data
|
||||
|
||||
**"Analysis shows low success rate"**
|
||||
- System is helping you identify what to improve
|
||||
- Follow recommendations to optimize
|
||||
- Learn from successful growers
|
||||
- Don't be discouraged - this is how you learn!
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Issues**: GitHub Issues for bugs
|
||||
- **Questions**: Community forum for growing advice
|
||||
- **Contributions**: PRs welcome for new features
|
||||
- **Data privacy**: See PRIVACY.md
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The goal is learning and improvement, not perfection. Every data point helps you become a better grower!
|
||||
|
||||
Happy tracking! 🌱📊
|
||||
413
README.md
413
README.md
|
|
@ -1,11 +1,412 @@
|
|||
# example-marketing
|
||||
# 🌱 LocalGreenChain
|
||||
|
||||
An example marketing site built using Drupal + JSON:API.
|
||||
**A blockchain for plants that preserves lineage across clones and seeds, connecting growers worldwide.**
|
||||
|
||||
Pages are built from the Landing page node type and paragraphs sourced from `/drupal`.
|
||||
LocalGreenChain is a revolutionary plant tracking system that uses blockchain technology to create an immutable record of plant lineage. Track every clone, cutting, seed, and offspring throughout multiple generations while connecting with nearby growers who share your passion for plants.
|
||||
|
||||
See https://demo.next-drupal.org
|
||||
## ✨ Features
|
||||
|
||||
## License
|
||||
### 🌳 **Lineage Tracking**
|
||||
- Track every plant from its origin through unlimited generations
|
||||
- Record propagation methods (clone, seed, cutting, division, grafting)
|
||||
- Build comprehensive family trees showing ancestors, descendants, and siblings
|
||||
- Immutable blockchain ensures lineage data can never be lost or altered
|
||||
|
||||
Licensed under the [MIT license](https://github.com/chapter-three/next-drupal/blob/master/LICENSE).
|
||||
### 📍 **Geographic Connections**
|
||||
- Find plants and growers near your location
|
||||
- Discover plant clusters and hotspots in your area
|
||||
- Connect with people who have plants from the same lineage
|
||||
- Build local plant-sharing communities
|
||||
|
||||
### 🔗 **Blockchain Security**
|
||||
- Proof-of-work consensus ensures data integrity
|
||||
- Each plant registration is a permanent block in the chain
|
||||
- Cryptographic hashing prevents tampering
|
||||
- Distributed architecture for reliability
|
||||
|
||||
### 🌍 **Global Network**
|
||||
- Integration with plants.net API for plant identification
|
||||
- Track how your plants spread across the world
|
||||
- See global distribution statistics by species and location
|
||||
- Join a worldwide community of plant enthusiasts
|
||||
|
||||
### 🎨 **User-Friendly Interface**
|
||||
- Beautiful, intuitive web interface
|
||||
- Easy plant registration with geolocation support
|
||||
- Visual lineage explorer
|
||||
- Network statistics dashboard
|
||||
- Mobile-responsive design
|
||||
|
||||
### 🔒 **Privacy & Anonymity (Tor Integration)**
|
||||
- Anonymous plant registration with zero personal information
|
||||
- Location obfuscation (fuzzy, city, country, or hidden)
|
||||
- Tor hidden service support for .onion access
|
||||
- SOCKS proxy integration for outbound Tor connections
|
||||
- Privacy-preserving geolocation (share area, not exact location)
|
||||
- Pseudonymous identities and wallet addresses
|
||||
- End-to-end encrypted communications
|
||||
|
||||
### 🌍 **Environmental Tracking**
|
||||
- Comprehensive soil composition tracking (type, pH, drainage, amendments)
|
||||
- Complete nutrient monitoring (NPK, micronutrients, EC/TDS)
|
||||
- Climate conditions (temperature, humidity, airflow, CO2)
|
||||
- Lighting analysis (natural/artificial, hours, spectrum, PPFD/DLI)
|
||||
- Watering schedules and water quality monitoring
|
||||
- Container and growing location details
|
||||
- Pest, disease, and companion plant tracking
|
||||
- Growth metrics and health scoring
|
||||
- Environmental comparison between plants
|
||||
- Personalized growing recommendations
|
||||
- Success correlation analysis across network
|
||||
|
||||
**📘 Full Guide**: See [ENVIRONMENTAL_TRACKING.md](./ENVIRONMENTAL_TRACKING.md)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- **Bun 1.0+** (https://bun.sh) - A fast JavaScript runtime and package manager
|
||||
- Basic knowledge of plant propagation (optional but helpful!)
|
||||
|
||||
### Why Bun?
|
||||
LocalGreenChain uses Bun for faster installation, builds, and runtime performance:
|
||||
- **3x faster** package installation compared to npm
|
||||
- **Native TypeScript support** without configuration
|
||||
- **Built-in bundler** for optimal production builds
|
||||
- **Compatible** with npm packages
|
||||
|
||||
### Installing Bun
|
||||
If you don't have Bun installed:
|
||||
|
||||
```bash
|
||||
# macOS/Linux/WSL
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Or using npm
|
||||
npm install -g bun
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/localgreenchain.git
|
||||
cd localgreenchain
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Set up environment variables** (optional)
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and add your plants.net API key if you have one:
|
||||
```
|
||||
PLANTS_NET_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
4. **Run the development server**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
5. **Open your browser**
|
||||
Navigate to [http://localhost:3001](http://localhost:3001)
|
||||
|
||||
### 🧅 Tor Setup (Optional - For Privacy)
|
||||
|
||||
For anonymous plant sharing and privacy protection:
|
||||
|
||||
```bash
|
||||
# Quick start with Docker Compose
|
||||
docker-compose -f docker-compose.tor.yml up -d
|
||||
|
||||
# Or manual setup
|
||||
# 1. Install Tor
|
||||
sudo apt install tor
|
||||
|
||||
# 2. Configure Tor (copy example config)
|
||||
sudo cp tor/torrc.example /etc/tor/torrc
|
||||
|
||||
# 3. Start Tor
|
||||
sudo systemctl start tor
|
||||
|
||||
# 4. Enable Tor in LocalGreenChain
|
||||
# Edit .env and set: TOR_ENABLED=true
|
||||
|
||||
# 5. Start LocalGreenChain
|
||||
bun run start
|
||||
|
||||
# 6. Get your .onion address
|
||||
sudo cat /var/lib/tor/localgreenchain/hostname
|
||||
```
|
||||
|
||||
**📘 Full Guide**: See [TOR_SETUP.md](./TOR_SETUP.md) for complete instructions
|
||||
|
||||
## 📖 How It Works
|
||||
|
||||
### The Blockchain
|
||||
|
||||
LocalGreenChain uses a custom blockchain implementation where:
|
||||
|
||||
- **Each block** represents a plant registration or update
|
||||
- **Proof-of-work** mining ensures data integrity
|
||||
- **Cryptographic hashing** links blocks together
|
||||
- **Genesis block** initializes the chain
|
||||
|
||||
### Plant Lineage System
|
||||
|
||||
```
|
||||
Original Plant (Generation 0)
|
||||
├── Clone 1 (Generation 1)
|
||||
│ ├── Seed 1-1 (Generation 2)
|
||||
│ └── Cutting 1-2 (Generation 2)
|
||||
├── Clone 2 (Generation 1)
|
||||
└── Seed 3 (Generation 1)
|
||||
└── Clone 3-1 (Generation 2)
|
||||
```
|
||||
|
||||
Every plant maintains:
|
||||
- **Parent reference**: Which plant it came from
|
||||
- **Children array**: All offspring created from it
|
||||
- **Generation number**: How many steps from the original
|
||||
- **Propagation type**: How it was created
|
||||
|
||||
### Geographic Discovery
|
||||
|
||||
The system calculates distances between plants using the Haversine formula to:
|
||||
- Find plants within a specified radius
|
||||
- Cluster nearby plants to show hotspots
|
||||
- Suggest connections based on proximity and species
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
### For Home Gardeners
|
||||
- Track your plant collection and propagation success
|
||||
- Share clones with friends while maintaining the lineage record
|
||||
- Find local gardeners with similar plants for trading
|
||||
|
||||
### For Plant Nurseries
|
||||
- Provide customers with verified lineage information
|
||||
- Track all plants sold and their offspring
|
||||
- Build trust through transparent provenance
|
||||
|
||||
### For Conservation Projects
|
||||
- Document rare plant propagation efforts
|
||||
- Track genetic diversity across populations
|
||||
- Coordinate distribution of endangered species
|
||||
|
||||
### For Community Gardens
|
||||
- Manage shared plant collections
|
||||
- Track plant donations and their spread
|
||||
- Build local food security networks
|
||||
|
||||
### For Research
|
||||
- Study plant propagation success rates
|
||||
- Track genetic drift across generations
|
||||
- Analyze geographic distribution patterns
|
||||
|
||||
## 🛠️ API Reference
|
||||
|
||||
### Register a Plant
|
||||
```http
|
||||
POST /api/plants/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "plant-unique-id",
|
||||
"commonName": "Tomato",
|
||||
"scientificName": "Solanum lycopersicum",
|
||||
"location": {
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060,
|
||||
"city": "New York",
|
||||
"country": "USA"
|
||||
},
|
||||
"owner": {
|
||||
"id": "user-id",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Clone a Plant
|
||||
```http
|
||||
POST /api/plants/clone
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"parentPlantId": "parent-plant-id",
|
||||
"propagationType": "clone",
|
||||
"newPlant": {
|
||||
"location": {...},
|
||||
"owner": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Find Nearby Plants
|
||||
```http
|
||||
GET /api/plants/nearby?lat=40.7128&lon=-74.0060&radius=50
|
||||
```
|
||||
|
||||
### Get Plant Lineage
|
||||
```http
|
||||
GET /api/plants/lineage/plant-id
|
||||
```
|
||||
|
||||
### Search Plants
|
||||
```http
|
||||
GET /api/plants/search?q=tomato&type=species
|
||||
```
|
||||
|
||||
### Get Network Statistics
|
||||
```http
|
||||
GET /api/plants/network
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Runtime**: Bun (fast JavaScript runtime and package manager)
|
||||
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
||||
- **Blockchain**: Custom implementation with proof-of-work
|
||||
- **Storage**: JSON file-based (production can use database)
|
||||
- **APIs**: RESTful endpoints
|
||||
- **Geolocation**: Haversine distance calculation, OpenStreetMap geocoding
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
localgreenchain/
|
||||
├── lib/
|
||||
│ ├── blockchain/
|
||||
│ │ ├── types.ts # Type definitions
|
||||
│ │ ├── PlantBlock.ts # Block implementation
|
||||
│ │ ├── PlantChain.ts # Blockchain logic
|
||||
│ │ └── manager.ts # Blockchain singleton
|
||||
│ └── services/
|
||||
│ ├── plantsnet.ts # Plants.net API integration
|
||||
│ └── geolocation.ts # Location services
|
||||
├── pages/
|
||||
│ ├── index.tsx # Home page
|
||||
│ ├── plants/
|
||||
│ │ ├── register.tsx # Register new plant
|
||||
│ │ ├── clone.tsx # Clone existing plant
|
||||
│ │ ├── explore.tsx # Browse network
|
||||
│ │ └── [id].tsx # Plant details
|
||||
│ └── api/
|
||||
│ └── plants/ # API endpoints
|
||||
└── data/
|
||||
└── plantchain.json # Blockchain storage
|
||||
```
|
||||
|
||||
## 🌐 Scaling for Mass Adoption
|
||||
|
||||
### Current Implementation
|
||||
- **Single-server blockchain**: Good for 10,000s of plants
|
||||
- **File-based storage**: Simple and portable
|
||||
- **Client-side rendering**: Fast initial setup
|
||||
|
||||
### Scaling Recommendations
|
||||
|
||||
#### For 100,000+ Plants
|
||||
1. **Switch to database**: PostgreSQL or MongoDB
|
||||
2. **Add caching**: Redis for frequently accessed data
|
||||
3. **Optimize mining**: Reduce difficulty or use alternative consensus
|
||||
|
||||
#### For 1,000,000+ Plants
|
||||
1. **Distributed blockchain**: Multiple nodes with consensus
|
||||
2. **Sharding**: Geographic or species-based partitioning
|
||||
3. **CDN**: Serve static content globally
|
||||
4. **API rate limiting**: Protect against abuse
|
||||
|
||||
#### For Global Scale
|
||||
1. **Blockchain network**: Peer-to-peer node system
|
||||
2. **Mobile apps**: Native iOS/Android applications
|
||||
3. **Offline support**: Sync when connection available
|
||||
4. **Federation**: Regional chains with cross-chain references
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Here's how you can help:
|
||||
|
||||
1. **Add features**: New propagation types, plant care tracking, etc.
|
||||
2. **Improve UI**: Design enhancements, accessibility
|
||||
3. **Optimize blockchain**: Better consensus algorithms
|
||||
4. **Mobile app**: React Native implementation
|
||||
5. **Documentation**: Tutorials, translations
|
||||
|
||||
### Development Process
|
||||
```bash
|
||||
# Create a feature branch
|
||||
git checkout -b feature/amazing-feature
|
||||
|
||||
# Make your changes
|
||||
# ...
|
||||
|
||||
# Run tests (when available)
|
||||
bun test
|
||||
|
||||
# Commit with clear message
|
||||
git commit -m "Add amazing feature"
|
||||
|
||||
# Push and create pull request
|
||||
git push origin feature/amazing-feature
|
||||
```
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Inspired by the global plant-sharing community
|
||||
- Built with Next.js and React
|
||||
- Blockchain concept adapted for plant tracking
|
||||
- Geocoding powered by OpenStreetMap Nominatim
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/localgreenchain/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/localgreenchain/discussions)
|
||||
- **Email**: support@localgreenchain.org
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### Phase 1: Core Features ✅
|
||||
- [x] Blockchain implementation
|
||||
- [x] Plant registration and cloning
|
||||
- [x] Lineage tracking
|
||||
- [x] Geographic search
|
||||
- [x] Web interface
|
||||
|
||||
### Phase 2: Enhanced Features (Q2 2025)
|
||||
- [ ] User authentication
|
||||
- [ ] Plant care reminders
|
||||
- [ ] Photo uploads
|
||||
- [ ] QR code plant tags
|
||||
- [ ] Mobile app (React Native)
|
||||
|
||||
### Phase 3: Community Features (Q3 2025)
|
||||
- [ ] Trading marketplace
|
||||
- [ ] Events and meetups
|
||||
- [ ] Expert advice forum
|
||||
- [ ] Plant care wiki
|
||||
- [ ] Achievements and badges
|
||||
|
||||
### Phase 4: Advanced Features (Q4 2025)
|
||||
- [ ] DNA/genetic tracking integration
|
||||
- [ ] AI plant identification
|
||||
- [ ] Environmental impact tracking
|
||||
- [ ] Carbon sequestration calculations
|
||||
- [ ] Partnership with botanical gardens
|
||||
|
||||
---
|
||||
|
||||
**Built with 💚 for the planet**
|
||||
|
||||
Start your plant lineage journey today! 🌱
|
||||
|
|
|
|||
455
TOR_SETUP.md
Normal file
455
TOR_SETUP.md
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
# 🧅 Tor Integration Guide for LocalGreenChain
|
||||
|
||||
This guide explains how to set up LocalGreenChain with Tor for maximum privacy and anonymity when sharing plant lineages.
|
||||
|
||||
## Why Use Tor with LocalGreenChain?
|
||||
|
||||
### Privacy Benefits
|
||||
- **Anonymous Plant Registration**: Register plants without revealing your identity
|
||||
- **Location Privacy**: Share general area without exposing exact home address
|
||||
- **IP Protection**: Hide your IP address from other users and the network
|
||||
- **Censorship Resistance**: Access the network even in restrictive environments
|
||||
- **Secure Sharing**: Share plant clones with trusted community members anonymously
|
||||
|
||||
### Use Cases
|
||||
- **Privacy-Conscious Growers**: Don't want to advertise exact plant locations
|
||||
- **Sensitive Species**: Medicinal plants, rare species, or regulated botanicals
|
||||
- **Community Building**: Connect with local growers without revealing identity
|
||||
- **Research**: Anonymous data collection for botanical research
|
||||
- **Security**: Protect against unwanted visitors or theft
|
||||
|
||||
## Table of Contents
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Installation Methods](#installation-methods)
|
||||
3. [Configuration](#configuration)
|
||||
4. [Running as Hidden Service](#running-as-hidden-service)
|
||||
5. [Using Tor Browser](#using-tor-browser)
|
||||
6. [Privacy Best Practices](#privacy-best-practices)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
The easiest way to run LocalGreenChain with Tor:
|
||||
|
||||
```bash
|
||||
# Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and enable Tor
|
||||
nano .env
|
||||
# Set: TOR_ENABLED=true
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose -f docker-compose.tor.yml up -d
|
||||
|
||||
# Check if Tor is running
|
||||
docker logs localgreenchain-tor
|
||||
|
||||
# Get your onion address
|
||||
docker exec localgreenchain-tor cat /var/lib/tor/hidden_service/hostname
|
||||
```
|
||||
|
||||
Your LocalGreenChain instance is now accessible via:
|
||||
- Local: http://localhost:3001
|
||||
- Onion: http://[your-address].onion (share this!)
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
1. **Install Tor**
|
||||
2. **Configure Tor for LocalGreenChain**
|
||||
3. **Start LocalGreenChain with Tor enabled**
|
||||
|
||||
---
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Install Tor
|
||||
sudo apt update
|
||||
sudo apt install tor
|
||||
|
||||
# Configure Tor for LocalGreenChain
|
||||
sudo cp tor/torrc.example /etc/tor/torrc
|
||||
|
||||
# Edit configuration
|
||||
sudo nano /etc/tor/torrc
|
||||
|
||||
# Create hidden service directory
|
||||
sudo mkdir -p /var/lib/tor/localgreenchain
|
||||
sudo chown -R debian-tor:debian-tor /var/lib/tor/localgreenchain
|
||||
sudo chmod 700 /var/lib/tor/localgreenchain
|
||||
|
||||
# Start Tor
|
||||
sudo systemctl start tor
|
||||
sudo systemctl enable tor
|
||||
|
||||
# Check status
|
||||
sudo systemctl status tor
|
||||
|
||||
# Get your onion address (wait ~1 minute for generation)
|
||||
sudo cat /var/lib/tor/localgreenchain/hostname
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Install Tor via Homebrew
|
||||
brew install tor
|
||||
|
||||
# Copy configuration
|
||||
cp tor/torrc.example /usr/local/etc/tor/torrc
|
||||
|
||||
# Edit configuration
|
||||
nano /usr/local/etc/tor/torrc
|
||||
|
||||
# Create hidden service directory
|
||||
mkdir -p ~/Library/Application\ Support/tor/localgreenchain
|
||||
chmod 700 ~/Library/Application\ Support/tor/localgreenchain
|
||||
|
||||
# Update torrc with your path
|
||||
# HiddenServiceDir ~/Library/Application Support/tor/localgreenchain
|
||||
|
||||
# Start Tor
|
||||
brew services start tor
|
||||
|
||||
# Get your onion address
|
||||
cat ~/Library/Application\ Support/tor/localgreenchain/hostname
|
||||
```
|
||||
|
||||
### Windows (WSL)
|
||||
|
||||
```bash
|
||||
# Install WSL if not already installed
|
||||
# Then follow Linux instructions above
|
||||
|
||||
# Or use Tor Expert Bundle
|
||||
# Download from: https://www.torproject.org/download/tor/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Edit `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable Tor
|
||||
TOR_ENABLED=true
|
||||
|
||||
# Tor SOCKS proxy (default)
|
||||
TOR_SOCKS_HOST=127.0.0.1
|
||||
TOR_SOCKS_PORT=9050
|
||||
|
||||
# Tor control port
|
||||
TOR_CONTROL_PORT=9051
|
||||
|
||||
# Hidden service directory
|
||||
TOR_HIDDEN_SERVICE_DIR=/var/lib/tor/localgreenchain
|
||||
|
||||
# Privacy defaults
|
||||
DEFAULT_PRIVACY_MODE=standard
|
||||
ALLOW_ANONYMOUS_REGISTRATION=true
|
||||
LOCATION_OBFUSCATION_DEFAULT=fuzzy
|
||||
```
|
||||
|
||||
### Tor Configuration (torrc)
|
||||
|
||||
Minimal configuration in `/etc/tor/torrc`:
|
||||
|
||||
```
|
||||
# SOCKS proxy
|
||||
SocksPort 9050
|
||||
|
||||
# Hidden Service for LocalGreenChain
|
||||
HiddenServiceDir /var/lib/tor/localgreenchain/
|
||||
HiddenServicePort 80 127.0.0.1:3001
|
||||
|
||||
# Optional: Multiple ports
|
||||
# HiddenServicePort 443 127.0.0.1:3001
|
||||
|
||||
# Logging
|
||||
Log notice file /var/log/tor/notices.log
|
||||
|
||||
# Privacy settings
|
||||
IsolateDestAddr 1
|
||||
IsolateDestPort 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running as Hidden Service
|
||||
|
||||
### Start LocalGreenChain
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Start in production mode
|
||||
bun run build
|
||||
bun run start
|
||||
|
||||
# Or development mode
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Verify Hidden Service
|
||||
|
||||
```bash
|
||||
# Check if Tor created keys
|
||||
ls -la /var/lib/tor/localgreenchain/
|
||||
|
||||
# Should see:
|
||||
# - hostname (your .onion address)
|
||||
# - hs_ed25519_public_key
|
||||
# - hs_ed25519_secret_key
|
||||
|
||||
# Get your onion address
|
||||
cat /var/lib/tor/localgreenchain/hostname
|
||||
```
|
||||
|
||||
### Share Your Onion Address
|
||||
|
||||
Your `.onion` address looks like:
|
||||
```
|
||||
abc123def456ghi789.onion
|
||||
```
|
||||
|
||||
Share this with trusted community members to allow anonymous access!
|
||||
|
||||
---
|
||||
|
||||
## Using Tor Browser
|
||||
|
||||
### As a User (Accessing LocalGreenChain via Tor)
|
||||
|
||||
1. **Download Tor Browser**
|
||||
- Visit: https://www.torproject.org/download/
|
||||
- Install for your operating system
|
||||
|
||||
2. **Connect to Tor Network**
|
||||
- Launch Tor Browser
|
||||
- Click "Connect" to establish Tor connection
|
||||
|
||||
3. **Access LocalGreenChain**
|
||||
- Option A: Via onion address (recommended)
|
||||
```
|
||||
http://[your-onion-address].onion
|
||||
```
|
||||
- Option B: Via clearnet (still anonymous)
|
||||
```
|
||||
http://your-domain.com
|
||||
```
|
||||
|
||||
4. **Register Plants Anonymously**
|
||||
- Go to "Anonymous Registration" page
|
||||
- Your connection will be detected as coming from Tor
|
||||
- All privacy features automatically enabled
|
||||
|
||||
### Privacy Indicators
|
||||
|
||||
LocalGreenChain will show you:
|
||||
- 🧅 "Tor Active" badge when connected via Tor
|
||||
- Privacy recommendations based on connection type
|
||||
- Tor circuit information (country, not your IP)
|
||||
|
||||
---
|
||||
|
||||
## Privacy Best Practices
|
||||
|
||||
### For Maximum Anonymity
|
||||
|
||||
1. **Always Use Tor Browser**
|
||||
- Don't access via regular browser
|
||||
- Tor Browser includes additional privacy protections
|
||||
|
||||
2. **Enable Anonymous Mode**
|
||||
- Use `/plants/register-anonymous` page
|
||||
- Generate random IDs and pseudonyms
|
||||
- Don't reuse usernames from other sites
|
||||
|
||||
3. **Location Privacy**
|
||||
- Use "Fuzzy" or "City" level location sharing
|
||||
- Never share exact coordinates
|
||||
- Consider using "Hidden" for sensitive plants
|
||||
|
||||
4. **Operational Security (OpSec)**
|
||||
- Don't include identifiable info in plant notes
|
||||
- Use different pseudonyms for different plant types
|
||||
- Don't correlate with social media accounts
|
||||
- Clear browser data after each session
|
||||
|
||||
5. **Network Security**
|
||||
- Only share your .onion address with trusted people
|
||||
- Use secure channels (encrypted messaging) to share addresses
|
||||
- Rotate your hidden service periodically if needed
|
||||
|
||||
### Privacy Levels Explained
|
||||
|
||||
| Level | Location Accuracy | Best For |
|
||||
|-------|------------------|----------|
|
||||
| **Exact** | ~100m | Public gardens, commercial nurseries |
|
||||
| **Fuzzy** | 1-5km radius | Home gardens, privacy-conscious sharing |
|
||||
| **City** | ~10km grid | Regional plant trading |
|
||||
| **Country** | ~100km grid | National distribution tracking |
|
||||
| **Hidden** | No location | Maximum privacy, sensitive species |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tor Won't Start
|
||||
|
||||
```bash
|
||||
# Check Tor status
|
||||
sudo systemctl status tor
|
||||
|
||||
# View logs
|
||||
sudo tail -f /var/log/tor/notices.log
|
||||
|
||||
# Common issues:
|
||||
# 1. Port 9050 already in use
|
||||
sudo lsof -i :9050
|
||||
|
||||
# 2. Permission issues
|
||||
sudo chown -R debian-tor:debian-tor /var/lib/tor
|
||||
sudo chmod 700 /var/lib/tor/localgreenchain
|
||||
```
|
||||
|
||||
### Hidden Service Not Accessible
|
||||
|
||||
```bash
|
||||
# Verify Tor is running
|
||||
pgrep tor
|
||||
|
||||
# Check if hostname file exists
|
||||
cat /var/lib/tor/localgreenchain/hostname
|
||||
|
||||
# Verify LocalGreenChain is running
|
||||
curl http://localhost:3001
|
||||
|
||||
# Check Tor logs for errors
|
||||
sudo tail -f /var/log/tor/notices.log
|
||||
```
|
||||
|
||||
### "Tor Status: Not Available"
|
||||
|
||||
1. Check if Tor daemon is running
|
||||
2. Verify SOCKS port (9050) is open
|
||||
3. Check firewall settings
|
||||
4. Restart Tor service
|
||||
|
||||
```bash
|
||||
sudo systemctl restart tor
|
||||
```
|
||||
|
||||
### Slow Onion Connection
|
||||
|
||||
This is normal! Tor routes through multiple nodes:
|
||||
- First connection: 30-60 seconds
|
||||
- Subsequent loads: 5-15 seconds
|
||||
- Plant operations: Near instant (local blockchain)
|
||||
|
||||
---
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Running Multiple Hidden Services
|
||||
|
||||
Edit `/etc/tor/torrc`:
|
||||
|
||||
```
|
||||
# LocalGreenChain (public)
|
||||
HiddenServiceDir /var/lib/tor/localgreenchain-public/
|
||||
HiddenServicePort 80 127.0.0.1:3001
|
||||
|
||||
# LocalGreenChain (private - invite only)
|
||||
HiddenServiceDir /var/lib/tor/localgreenchain-private/
|
||||
HiddenServicePort 80 127.0.0.1:3002
|
||||
```
|
||||
|
||||
### Client Authentication (v3 Onions)
|
||||
|
||||
Restrict access to authorized users only:
|
||||
|
||||
```
|
||||
# In torrc
|
||||
HiddenServiceDir /var/lib/tor/localgreenchain/
|
||||
HiddenServicePort 80 127.0.0.1:3001
|
||||
HiddenServiceAuthorizeClient stealth alice,bob
|
||||
```
|
||||
|
||||
### Monitoring Tor Traffic
|
||||
|
||||
```bash
|
||||
# Real-time connection monitoring
|
||||
sudo nyx
|
||||
|
||||
# Or arm (older tool)
|
||||
sudo arm
|
||||
```
|
||||
|
||||
### Backup Your Hidden Service Keys
|
||||
|
||||
**IMPORTANT**: Your `.onion` address is tied to your keys!
|
||||
|
||||
```bash
|
||||
# Backup keys
|
||||
sudo cp -r /var/lib/tor/localgreenchain ~/tor-backup/
|
||||
|
||||
# Restore keys (on new server)
|
||||
sudo cp -r ~/tor-backup/* /var/lib/tor/localgreenchain/
|
||||
sudo chown -R debian-tor:debian-tor /var/lib/tor/localgreenchain
|
||||
sudo systemctl restart tor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### What Tor DOES Protect
|
||||
✅ Your IP address from other users
|
||||
✅ Your browsing from your ISP
|
||||
✅ Your location from the network
|
||||
✅ Your identity when using anonymous mode
|
||||
|
||||
### What Tor DOESN'T Protect
|
||||
❌ Poor operational security (sharing identifying info)
|
||||
❌ Malware on your computer
|
||||
❌ Logging in with real accounts
|
||||
❌ Data you voluntarily share
|
||||
|
||||
### Remember
|
||||
- **Tor provides anonymity, not security**
|
||||
- Use HTTPS even over Tor (LocalGreenChain supports this)
|
||||
- Don't mix anonymous and identified activities
|
||||
- Keep Tor Browser up to date
|
||||
- Trust the process - Tor has protected millions of users
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **LocalGreenChain Tor Issues**: https://github.com/yourusername/localgreenchain/issues
|
||||
- **Tor Project**: https://support.torproject.org
|
||||
- **Privacy Community**: https://www.reddit.com/r/TOR
|
||||
- **Security Audit**: See SECURITY.md
|
||||
|
||||
## Legal Notice
|
||||
|
||||
Using Tor is legal in most countries. However:
|
||||
- Check local laws regarding Tor usage
|
||||
- Using Tor for illegal activities is still illegal
|
||||
- LocalGreenChain is for botanical education and legal plant sharing
|
||||
- Respect plant import/export regulations
|
||||
- Some plants may be regulated or controlled substances
|
||||
|
||||
Stay safe, stay private, and happy growing! 🌱🧅
|
||||
19
bunfig.toml
Normal file
19
bunfig.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Bun configuration for LocalGreenChain
|
||||
|
||||
[install]
|
||||
# Install dependencies from the lockfile if it exists
|
||||
frozenLockfile = false
|
||||
|
||||
# Configure which registries to use
|
||||
registry = "https://registry.npmjs.org/"
|
||||
|
||||
# Cache directory
|
||||
cache = "node_modules/.cache/bun"
|
||||
|
||||
[run]
|
||||
# Automatically install dependencies when running scripts
|
||||
autoInstall = true
|
||||
|
||||
[test]
|
||||
# Test configuration (when tests are added)
|
||||
preload = []
|
||||
334
components/EnvironmentalDisplay.tsx
Normal file
334
components/EnvironmentalDisplay.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { GrowingEnvironment } from '../lib/environment/types';
|
||||
import { EnvironmentalRecommendation } from '../lib/environment/types';
|
||||
|
||||
interface EnvironmentalDisplayProps {
|
||||
environment: GrowingEnvironment;
|
||||
plantId: string;
|
||||
showRecommendations?: boolean;
|
||||
}
|
||||
|
||||
export default function EnvironmentalDisplay({
|
||||
environment,
|
||||
plantId,
|
||||
showRecommendations = true,
|
||||
}: EnvironmentalDisplayProps) {
|
||||
const [recommendations, setRecommendations] = useState<EnvironmentalRecommendation[]>([]);
|
||||
const [healthScore, setHealthScore] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (showRecommendations) {
|
||||
fetchRecommendations();
|
||||
}
|
||||
}, [plantId]);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/environment/recommendations?plantId=${plantId}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRecommendations(data.recommendations);
|
||||
setHealthScore(data.environmentalHealth);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recommendations:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Environmental Health Score */}
|
||||
{healthScore !== null && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg p-6 border border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
Environmental Health Score
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Overall assessment of growing conditions
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-5xl font-bold ${getScoreColor(healthScore)}`}>
|
||||
{healthScore}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all ${getScoreBgColor(healthScore)}`}
|
||||
style={{ width: `${healthScore}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mt-2">{getScoreInterpretation(healthScore)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{showRecommendations && recommendations.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
||||
📋 Recommendations ({recommendations.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{recommendations.map((rec, idx) => (
|
||||
<RecommendationCard key={idx} recommendation={rec} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Soil Information */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
🌱 Soil Composition
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<DataPoint label="Type" value={formatValue(environment.soil.type)} />
|
||||
<DataPoint label="pH" value={environment.soil.pH.toFixed(1)} />
|
||||
<DataPoint label="Texture" value={formatValue(environment.soil.texture)} />
|
||||
<DataPoint label="Drainage" value={formatValue(environment.soil.drainage)} />
|
||||
<DataPoint label="Organic Matter" value={`${environment.soil.organicMatter}%`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Climate Conditions */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
🌡️ Climate Conditions
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<DataPoint label="Day Temp" value={`${environment.climate.temperatureDay}°C`} />
|
||||
<DataPoint label="Night Temp" value={`${environment.climate.temperatureNight}°C`} />
|
||||
<DataPoint label="Humidity" value={`${environment.climate.humidityAverage}%`} />
|
||||
<DataPoint label="Airflow" value={formatValue(environment.climate.airflow)} />
|
||||
<DataPoint label="Ventilation" value={formatValue(environment.climate.ventilation)} />
|
||||
{environment.climate.zone && (
|
||||
<DataPoint label="Hardiness Zone" value={environment.climate.zone} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lighting */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
☀️ Lighting
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<DataPoint label="Type" value={formatValue(environment.lighting.type)} />
|
||||
{environment.lighting.naturalLight && (
|
||||
<>
|
||||
<DataPoint
|
||||
label="Exposure"
|
||||
value={formatValue(environment.lighting.naturalLight.exposure)}
|
||||
/>
|
||||
<DataPoint
|
||||
label="Hours/Day"
|
||||
value={`${environment.lighting.naturalLight.hoursPerDay}h`}
|
||||
/>
|
||||
<DataPoint
|
||||
label="Direction"
|
||||
value={formatValue(environment.lighting.naturalLight.direction)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{environment.lighting.artificialLight && (
|
||||
<>
|
||||
<DataPoint
|
||||
label="Light Type"
|
||||
value={formatValue(environment.lighting.artificialLight.type)}
|
||||
/>
|
||||
<DataPoint
|
||||
label="Hours/Day"
|
||||
value={`${environment.lighting.artificialLight.hoursPerDay}h`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location & Container */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">📍 Location</h3>
|
||||
<div className="space-y-3">
|
||||
<DataPoint label="Type" value={formatValue(environment.location.type)} />
|
||||
{environment.location.room && (
|
||||
<DataPoint label="Room" value={environment.location.room} />
|
||||
)}
|
||||
{environment.location.elevation && (
|
||||
<DataPoint label="Elevation" value={`${environment.location.elevation}m`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{environment.container && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">🪴 Container</h3>
|
||||
<div className="space-y-3">
|
||||
<DataPoint label="Type" value={formatValue(environment.container.type)} />
|
||||
{environment.container.material && (
|
||||
<DataPoint label="Material" value={formatValue(environment.container.material)} />
|
||||
)}
|
||||
{environment.container.size && (
|
||||
<DataPoint label="Size" value={environment.container.size} />
|
||||
)}
|
||||
<DataPoint
|
||||
label="Drainage"
|
||||
value={environment.container.drainage === 'yes' ? '✓ Yes' : '✗ No'}
|
||||
highlight={environment.container.drainage === 'no' ? 'red' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watering */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">💧 Watering</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<DataPoint label="Method" value={formatValue(environment.watering.method)} />
|
||||
<DataPoint label="Source" value={formatValue(environment.watering.waterSource)} />
|
||||
{environment.watering.frequency && (
|
||||
<DataPoint label="Frequency" value={environment.watering.frequency} />
|
||||
)}
|
||||
{environment.watering.waterQuality?.pH && (
|
||||
<DataPoint label="Water pH" value={environment.watering.waterQuality.pH.toFixed(1)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nutrients */}
|
||||
{environment.nutrients && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">🧪 Nutrients</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<DataPoint
|
||||
label="NPK"
|
||||
value={`${environment.nutrients.nitrogen}-${environment.nutrients.phosphorus}-${environment.nutrients.potassium}`}
|
||||
/>
|
||||
{environment.nutrients.ec && (
|
||||
<DataPoint label="EC" value={`${environment.nutrients.ec} mS/cm`} />
|
||||
)}
|
||||
{environment.nutrients.tds && (
|
||||
<DataPoint label="TDS" value={`${environment.nutrients.tds} ppm`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Surroundings */}
|
||||
{environment.surroundings && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">🌿 Surroundings</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{environment.surroundings.ecosystem && (
|
||||
<DataPoint label="Ecosystem" value={formatValue(environment.surroundings.ecosystem)} />
|
||||
)}
|
||||
{environment.surroundings.windExposure && (
|
||||
<DataPoint label="Wind" value={formatValue(environment.surroundings.windExposure)} />
|
||||
)}
|
||||
{environment.surroundings.companionPlants && environment.surroundings.companionPlants.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm font-medium text-gray-700">Companion Plants:</p>
|
||||
<p className="text-sm text-gray-900">{environment.surroundings.companionPlants.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation }: { recommendation: EnvironmentalRecommendation }) {
|
||||
const priorityColors = {
|
||||
critical: 'border-red-500 bg-red-50',
|
||||
high: 'border-orange-500 bg-orange-50',
|
||||
medium: 'border-yellow-500 bg-yellow-50',
|
||||
low: 'border-blue-500 bg-blue-50',
|
||||
};
|
||||
|
||||
const priorityIcons = {
|
||||
critical: '🚨',
|
||||
high: '⚠️',
|
||||
medium: '💡',
|
||||
low: 'ℹ️',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-l-4 p-4 rounded-r-lg ${priorityColors[recommendation.priority]}`}>
|
||||
<div className="flex items-start">
|
||||
<span className="text-2xl mr-3">{priorityIcons[recommendation.priority]}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-semibold text-gray-900">{recommendation.issue}</h4>
|
||||
<span className="text-xs uppercase font-semibold text-gray-600">
|
||||
{recommendation.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-2">
|
||||
<strong>Recommendation:</strong> {recommendation.recommendation}
|
||||
</p>
|
||||
<p className="text-xs text-gray-700">
|
||||
<strong>Impact:</strong> {recommendation.impact}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPoint({
|
||||
label,
|
||||
value,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: 'red' | 'green';
|
||||
}) {
|
||||
const highlightClass = highlight === 'red' ? 'text-red-600 font-semibold' : highlight === 'green' ? 'text-green-600 font-semibold' : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">{label}</p>
|
||||
<p className={`text-sm font-medium text-gray-900 ${highlightClass}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 90) return 'text-green-600';
|
||||
if (score >= 75) return 'text-lime-600';
|
||||
if (score >= 60) return 'text-yellow-600';
|
||||
if (score >= 40) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
function getScoreBgColor(score: number): string {
|
||||
if (score >= 90) return 'bg-green-600';
|
||||
if (score >= 75) return 'bg-lime-600';
|
||||
if (score >= 60) return 'bg-yellow-600';
|
||||
if (score >= 40) return 'bg-orange-600';
|
||||
return 'bg-red-600';
|
||||
}
|
||||
|
||||
function getScoreInterpretation(score: number): string {
|
||||
if (score >= 90) return '🌟 Excellent conditions - your plant should thrive!';
|
||||
if (score >= 75) return '✓ Good conditions with minor room for improvement';
|
||||
if (score >= 60) return '⚡ Adequate conditions, several optimization opportunities';
|
||||
if (score >= 40) return '⚠️ Suboptimal conditions, address high-priority issues';
|
||||
return '🚨 Poor conditions, immediate action needed';
|
||||
}
|
||||
708
components/EnvironmentalForm.tsx
Normal file
708
components/EnvironmentalForm.tsx
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
GrowingEnvironment,
|
||||
SoilComposition,
|
||||
ClimateConditions,
|
||||
LightingConditions,
|
||||
NutrientProfile,
|
||||
WateringSchedule,
|
||||
ContainerInfo,
|
||||
EnvironmentLocation,
|
||||
SurroundingEnvironment,
|
||||
} from '../lib/environment/types';
|
||||
|
||||
interface EnvironmentalFormProps {
|
||||
value: Partial<GrowingEnvironment>;
|
||||
onChange: (env: Partial<GrowingEnvironment>) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function EnvironmentalForm({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: EnvironmentalFormProps) {
|
||||
const [activeSection, setActiveSection] = useState<string>('soil');
|
||||
|
||||
const updateSection = <K extends keyof GrowingEnvironment>(
|
||||
section: K,
|
||||
updates: Partial<GrowingEnvironment[K]>
|
||||
) => {
|
||||
onChange({
|
||||
...value,
|
||||
[section]: {
|
||||
...value[section],
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: 'soil', name: '🌱 Soil', icon: '🌱' },
|
||||
{ id: 'nutrients', name: '🧪 Nutrients', icon: '🧪' },
|
||||
{ id: 'lighting', name: '☀️ Light', icon: '☀️' },
|
||||
{ id: 'climate', name: '🌡️ Climate', icon: '🌡️' },
|
||||
{ id: 'location', name: '📍 Location', icon: '📍' },
|
||||
{ id: 'container', name: '🪴 Container', icon: '🪴' },
|
||||
{ id: 'watering', name: '💧 Water', icon: '💧' },
|
||||
{ id: 'surroundings', name: '🌿 Surroundings', icon: '🌿' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg">
|
||||
{/* Section Tabs */}
|
||||
<div className="flex overflow-x-auto border-b border-gray-200 bg-gray-50">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition ${
|
||||
activeSection === section.id
|
||||
? 'border-b-2 border-green-600 text-green-600 bg-white'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{section.icon}</span>
|
||||
{section.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="p-6">
|
||||
{activeSection === 'soil' && (
|
||||
<SoilSection
|
||||
value={value.soil}
|
||||
onChange={(soil) => updateSection('soil', soil)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'nutrients' && (
|
||||
<NutrientsSection
|
||||
value={value.nutrients}
|
||||
onChange={(nutrients) => updateSection('nutrients', nutrients)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'lighting' && (
|
||||
<LightingSection
|
||||
value={value.lighting}
|
||||
onChange={(lighting) => updateSection('lighting', lighting)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'climate' && (
|
||||
<ClimateSection
|
||||
value={value.climate}
|
||||
onChange={(climate) => updateSection('climate', climate)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'location' && (
|
||||
<LocationSection
|
||||
value={value.location}
|
||||
onChange={(location) => updateSection('location', location)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'container' && (
|
||||
<ContainerSection
|
||||
value={value.container}
|
||||
onChange={(container) => updateSection('container', container)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'watering' && (
|
||||
<WateringSection
|
||||
value={value.watering}
|
||||
onChange={(watering) => updateSection('watering', watering)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'surroundings' && (
|
||||
<SurroundingsSection
|
||||
value={value.surroundings}
|
||||
onChange={(surroundings) => updateSection('surroundings', surroundings)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Soil Section Component
|
||||
function SoilSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<SoilComposition>;
|
||||
onChange: (soil: Partial<SoilComposition>) => void;
|
||||
}) {
|
||||
const soil = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Soil Composition</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Soil Type *
|
||||
</label>
|
||||
<select
|
||||
value={soil.type || 'loam'}
|
||||
onChange={(e) => onChange({ ...soil, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="clay">Clay</option>
|
||||
<option value="sand">Sand</option>
|
||||
<option value="silt">Silt</option>
|
||||
<option value="loam">Loam (balanced)</option>
|
||||
<option value="peat">Peat</option>
|
||||
<option value="chalk">Chalk</option>
|
||||
<option value="custom">Custom Mix</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Soil pH * <span className="text-xs text-gray-500">(most plants: 6.0-7.0)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="14"
|
||||
value={soil.pH || 6.5}
|
||||
onChange={(e) => onChange({ ...soil, pH: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{soil.pH && soil.pH < 5.5 && '⚠️ Acidic - may need lime'}
|
||||
{soil.pH && soil.pH >= 5.5 && soil.pH <= 7.5 && '✓ Good range'}
|
||||
{soil.pH && soil.pH > 7.5 && '⚠️ Alkaline - may need sulfur'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Texture</label>
|
||||
<select
|
||||
value={soil.texture || 'medium'}
|
||||
onChange={(e) => onChange({ ...soil, texture: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="heavy">Heavy (clay-rich)</option>
|
||||
<option value="medium">Medium (balanced)</option>
|
||||
<option value="light">Light (sandy)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Drainage</label>
|
||||
<select
|
||||
value={soil.drainage || 'good'}
|
||||
onChange={(e) => onChange({ ...soil, drainage: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="poor">Poor (stays wet)</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="good">Good</option>
|
||||
<option value="excellent">Excellent (fast draining)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Organic Matter % <span className="text-xs text-gray-500">(ideal: 5-10%)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={soil.organicMatter || 5}
|
||||
onChange={(e) => onChange({ ...soil, organicMatter: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
💡 <strong>Tip:</strong> Test soil pH with a meter or test kit for accuracy. Most vegetables prefer pH 6.0-7.0.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nutrients Section Component
|
||||
function NutrientsSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<NutrientProfile>;
|
||||
onChange: (nutrients: Partial<NutrientProfile>) => void;
|
||||
}) {
|
||||
const nutrients = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Nutrient Profile</h3>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Primary Nutrients (NPK)</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nitrogen (N) %
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={nutrients.nitrogen || 0}
|
||||
onChange={(e) => onChange({ ...nutrients, nitrogen: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phosphorus (P) %
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={nutrients.phosphorus || 0}
|
||||
onChange={(e) => onChange({ ...nutrients, phosphorus: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Potassium (K) %
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={nutrients.potassium || 0}
|
||||
onChange={(e) => onChange({ ...nutrients, potassium: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">
|
||||
NPK ratio: {nutrients.nitrogen || 0}-{nutrients.phosphorus || 0}-{nutrients.potassium || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Leave at 0 if unknown. Use soil test kit for accurate NPK values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lighting Section Component
|
||||
function LightingSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<LightingConditions>;
|
||||
onChange: (lighting: Partial<LightingConditions>) => void;
|
||||
}) {
|
||||
const lighting = value || { type: 'natural' };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Lighting Conditions</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Light Type</label>
|
||||
<select
|
||||
value={lighting.type || 'natural'}
|
||||
onChange={(e) => onChange({ ...lighting, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="natural">Natural Sunlight</option>
|
||||
<option value="artificial">Artificial Light Only</option>
|
||||
<option value="mixed">Mixed (Natural + Artificial)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(lighting.type === 'natural' || lighting.type === 'mixed') && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-yellow-50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sun Exposure
|
||||
</label>
|
||||
<select
|
||||
value={lighting.naturalLight?.exposure || 'full_sun'}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...lighting,
|
||||
naturalLight: {
|
||||
...lighting.naturalLight,
|
||||
exposure: e.target.value as any,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="full_sun">Full Sun (6+ hours)</option>
|
||||
<option value="partial_sun">Partial Sun (4-6 hours)</option>
|
||||
<option value="partial_shade">Partial Shade (2-4 hours)</option>
|
||||
<option value="full_shade">Full Shade (<2 hours)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Hours of Sunlight/Day
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
value={lighting.naturalLight?.hoursPerDay || 8}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...lighting,
|
||||
naturalLight: {
|
||||
...lighting.naturalLight,
|
||||
hoursPerDay: parseInt(e.target.value),
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Most vegetables need 6+ hours of direct sunlight. Herbs can do well with 4-6 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Climate Section Component
|
||||
function ClimateSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<ClimateConditions>;
|
||||
onChange: (climate: Partial<ClimateConditions>) => void;
|
||||
}) {
|
||||
const climate = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Climate Conditions</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Day Temperature (°C) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={climate.temperatureDay || 22}
|
||||
onChange={(e) => onChange({ ...climate, temperatureDay: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Night Temperature (°C) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={climate.temperatureNight || 18}
|
||||
onChange={(e) => onChange({ ...climate, temperatureNight: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Average Humidity (%) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={climate.humidityAverage || 50}
|
||||
onChange={(e) => onChange({ ...climate, humidityAverage: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Airflow</label>
|
||||
<select
|
||||
value={climate.airflow || 'moderate'}
|
||||
onChange={(e) => onChange({ ...climate, airflow: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="none">None (still air)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="moderate">Moderate (good)</option>
|
||||
<option value="strong">Strong/Windy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Most plants thrive at 18-25°C. Good airflow prevents disease.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Location Section Component
|
||||
function LocationSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<EnvironmentLocation>;
|
||||
onChange: (location: Partial<EnvironmentLocation>) => void;
|
||||
}) {
|
||||
const location = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Growing Location</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Location Type *</label>
|
||||
<select
|
||||
value={location.type || 'outdoor'}
|
||||
onChange={(e) => onChange({ ...location, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="indoor">Indoor</option>
|
||||
<option value="outdoor">Outdoor</option>
|
||||
<option value="greenhouse">Greenhouse</option>
|
||||
<option value="polytunnel">Polytunnel</option>
|
||||
<option value="shade_house">Shade House</option>
|
||||
<option value="window">Window</option>
|
||||
<option value="balcony">Balcony</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Location type affects climate control and pest exposure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Container Section Component
|
||||
function ContainerSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<ContainerInfo>;
|
||||
onChange: (container: Partial<ContainerInfo>) => void;
|
||||
}) {
|
||||
const container = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Container Information</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Container Type</label>
|
||||
<select
|
||||
value={container.type || 'pot'}
|
||||
onChange={(e) => onChange({ ...container, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="pot">Pot</option>
|
||||
<option value="raised_bed">Raised Bed</option>
|
||||
<option value="ground">In Ground</option>
|
||||
<option value="fabric_pot">Fabric Pot</option>
|
||||
<option value="hydroponic">Hydroponic</option>
|
||||
<option value="hanging_basket">Hanging Basket</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Drainage * <span className="text-xs text-red-600">(CRITICAL!)</span>
|
||||
</label>
|
||||
<select
|
||||
value={container.drainage || 'yes'}
|
||||
onChange={(e) => onChange({ ...container, drainage: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="yes">Yes (has holes)</option>
|
||||
<option value="no">No (sealed)</option>
|
||||
</select>
|
||||
{container.drainage === 'no' && (
|
||||
<p className="mt-1 text-xs text-red-600 font-semibold">
|
||||
⚠️ WARNING: No drainage will likely kill your plant!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Size (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., 5 gallon, 30cm diameter"
|
||||
value={container.size || ''}
|
||||
onChange={(e) => onChange({ ...container, size: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Always ensure drainage! Sitting water = root rot = dead plant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Watering Section Component
|
||||
function WateringSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<WateringSchedule>;
|
||||
onChange: (watering: Partial<WateringSchedule>) => void;
|
||||
}) {
|
||||
const watering = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Watering Schedule</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
|
||||
<select
|
||||
value={watering.method || 'hand_water'}
|
||||
onChange={(e) => onChange({ ...watering, method: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="hand_water">Hand Watering</option>
|
||||
<option value="drip">Drip Irrigation</option>
|
||||
<option value="soaker_hose">Soaker Hose</option>
|
||||
<option value="sprinkler">Sprinkler</option>
|
||||
<option value="self_watering">Self-Watering</option>
|
||||
<option value="rain">Rain Fed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Water Source</label>
|
||||
<select
|
||||
value={watering.waterSource || 'tap'}
|
||||
onChange={(e) => onChange({ ...watering, waterSource: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="tap">Tap Water</option>
|
||||
<option value="well">Well Water</option>
|
||||
<option value="rain">Rain Water</option>
|
||||
<option value="filtered">Filtered</option>
|
||||
<option value="distilled">Distilled</option>
|
||||
<option value="RO">Reverse Osmosis (RO)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Frequency (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., daily, every 2-3 days"
|
||||
value={watering.frequency || ''}
|
||||
onChange={(e) => onChange({ ...watering, frequency: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Water when top inch of soil is dry. Overwatering kills more plants than underwatering!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Surroundings Section Component
|
||||
function SurroundingsSection({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Partial<SurroundingEnvironment>;
|
||||
onChange: (surroundings: Partial<SurroundingEnvironment>) => void;
|
||||
}) {
|
||||
const surroundings = value || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Surrounding Environment</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ecosystem Type</label>
|
||||
<select
|
||||
value={surroundings.ecosystem || 'urban'}
|
||||
onChange={(e) => onChange({ ...surroundings, ecosystem: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="urban">Urban</option>
|
||||
<option value="suburban">Suburban</option>
|
||||
<option value="rural">Rural</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="desert">Desert</option>
|
||||
<option value="coastal">Coastal</option>
|
||||
<option value="mountain">Mountain</option>
|
||||
<option value="tropical">Tropical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wind Exposure</label>
|
||||
<select
|
||||
value={surroundings.windExposure || 'moderate'}
|
||||
onChange={(e) => onChange({ ...surroundings, windExposure: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="sheltered">Sheltered (protected)</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="exposed">Exposed</option>
|
||||
<option value="windy">Very Windy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
💡 <strong>Tip:</strong> Track companion plants and pests to learn what works in your ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
components/PrivacySettings.tsx
Normal file
227
components/PrivacySettings.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { PrivacySettings as IPrivacySettings } from '../lib/privacy/anonymity';
|
||||
|
||||
interface PrivacySettingsProps {
|
||||
value: IPrivacySettings;
|
||||
onChange: (settings: IPrivacySettings) => void;
|
||||
showTorStatus?: boolean;
|
||||
}
|
||||
|
||||
export default function PrivacySettings({
|
||||
value,
|
||||
onChange,
|
||||
showTorStatus = true,
|
||||
}: PrivacySettingsProps) {
|
||||
const [torStatus, setTorStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTorStatus) {
|
||||
checkTorStatus();
|
||||
}
|
||||
}, [showTorStatus]);
|
||||
|
||||
const checkTorStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/privacy/tor-status');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTorStatus(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Tor status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof IPrivacySettings, newValue: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
[field]: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 border-2 border-purple-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
🔒 Privacy & Anonymity Settings
|
||||
</h2>
|
||||
{!loading && torStatus?.tor.connectionThroughTor && (
|
||||
<span className="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm font-semibold">
|
||||
🧅 Tor Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tor Status Banner */}
|
||||
{showTorStatus && !loading && (
|
||||
<div
|
||||
className={`mb-6 p-4 rounded-lg ${
|
||||
torStatus?.tor.connectionThroughTor
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-yellow-50 border border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<span className="text-2xl mr-3">
|
||||
{torStatus?.tor.connectionThroughTor ? '🧅' : '⚠️'}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
{torStatus?.tor.connectionThroughTor
|
||||
? 'Tor Connection Active'
|
||||
: 'Not Using Tor'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
{torStatus?.tor.connectionThroughTor
|
||||
? 'Your connection is anonymous and routed through the Tor network.'
|
||||
: 'For maximum privacy, consider accessing via Tor Browser.'}
|
||||
</p>
|
||||
{torStatus?.tor.onionAddress && (
|
||||
<p className="text-sm font-mono bg-gray-100 p-2 rounded mt-2">
|
||||
Onion Address: {torStatus.tor.onionAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anonymous Mode Toggle */}
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.anonymousMode}
|
||||
onChange={(e) => handleChange('anonymousMode', e.target.checked)}
|
||||
className="w-5 h-5 text-purple-600 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="ml-3 text-gray-900 font-medium">
|
||||
Enable Anonymous Mode
|
||||
</span>
|
||||
</label>
|
||||
<p className="ml-8 text-sm text-gray-600 mt-1">
|
||||
Generate random identifiers and hide personal information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Location Privacy */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Location Privacy Level
|
||||
</label>
|
||||
<select
|
||||
value={value.locationPrivacy}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'locationPrivacy',
|
||||
e.target.value as 'exact' | 'fuzzy' | 'city' | 'country' | 'hidden'
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="exact">📍 Exact Location (Public)</option>
|
||||
<option value="fuzzy">🎯 Fuzzy (±1-5km radius)</option>
|
||||
<option value="city">🏙️ City Level (~10km grid)</option>
|
||||
<option value="country">🌍 Country/Region (~100km grid)</option>
|
||||
<option value="hidden">🔒 Hidden (No location)</option>
|
||||
</select>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{value.locationPrivacy === 'exact' && (
|
||||
<span className="text-red-600 font-medium">
|
||||
⚠️ Warning: Exact location may reveal your home address
|
||||
</span>
|
||||
)}
|
||||
{value.locationPrivacy === 'fuzzy' && (
|
||||
<span>✓ Good balance of privacy and discoverability</span>
|
||||
)}
|
||||
{value.locationPrivacy === 'city' && (
|
||||
<span>✓ Only city-level information shared</span>
|
||||
)}
|
||||
{value.locationPrivacy === 'country' && (
|
||||
<span>✓ Only country/region visible</span>
|
||||
)}
|
||||
{value.locationPrivacy === 'hidden' && (
|
||||
<span className="text-purple-600 font-medium">
|
||||
🔒 Maximum privacy: Location completely hidden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Privacy */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Identity Privacy
|
||||
</label>
|
||||
<select
|
||||
value={value.identityPrivacy}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'identityPrivacy',
|
||||
e.target.value as 'real' | 'pseudonym' | 'anonymous'
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="real">👤 Real Name</option>
|
||||
<option value="pseudonym">🎭 Pseudonym</option>
|
||||
<option value="anonymous">🔒 Anonymous</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Share Plant Details */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.sharePlantDetails}
|
||||
onChange={(e) =>
|
||||
handleChange('sharePlantDetails', e.target.checked)
|
||||
}
|
||||
className="w-5 h-5 text-purple-600 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="ml-3 text-gray-900 font-medium">
|
||||
Share Plant Details (species, genus, family)
|
||||
</span>
|
||||
</label>
|
||||
<p className="ml-8 text-sm text-gray-600 mt-1">
|
||||
Uncheck to use generic plant identifiers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Privacy Summary */}
|
||||
<div className="mt-6 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Privacy Summary</h3>
|
||||
<ul className="text-sm text-purple-800 space-y-1">
|
||||
<li>
|
||||
• Location: {value.locationPrivacy === 'exact' ? 'Visible to all' : 'Protected'}
|
||||
</li>
|
||||
<li>
|
||||
• Identity: {value.identityPrivacy === 'real' ? 'Real name' : 'Protected'}
|
||||
</li>
|
||||
<li>
|
||||
• Plant Info: {value.sharePlantDetails ? 'Shared' : 'Generic'}
|
||||
</li>
|
||||
<li>
|
||||
• Mode: {value.anonymousMode ? 'Anonymous 🔒' : 'Standard'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{!loading && torStatus && torStatus.recommendations && (
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">💡 Recommendations</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
{torStatus.recommendations.map((rec: string, idx: number) => (
|
||||
<li key={idx}>• {rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
docker-compose.tor.yml
Normal file
67
docker-compose.tor.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Tor daemon
|
||||
tor:
|
||||
image: goldy/tor-hidden-service:latest
|
||||
container_name: localgreenchain-tor
|
||||
environment:
|
||||
# Hidden service configuration
|
||||
SERVICE_NAME: localgreenchain
|
||||
SERVICE_PORT: 80
|
||||
SERVICE_HOST: app
|
||||
SERVICE_HOST_PORT: 3001
|
||||
volumes:
|
||||
- tor-data:/var/lib/tor
|
||||
- ./tor/torrc.example:/etc/tor/torrc:ro
|
||||
ports:
|
||||
- "9050:9050" # SOCKS proxy
|
||||
- "9051:9051" # Control port
|
||||
networks:
|
||||
- localgreenchain-network
|
||||
restart: unless-stopped
|
||||
|
||||
# LocalGreenChain application
|
||||
app:
|
||||
build: .
|
||||
container_name: localgreenchain-app
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TOR_ENABLED=true
|
||||
- TOR_SOCKS_HOST=tor
|
||||
- TOR_SOCKS_PORT=9050
|
||||
- TOR_CONTROL_PORT=9051
|
||||
- TOR_HIDDEN_SERVICE_DIR=/var/lib/tor/hidden_service
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- tor-data:/var/lib/tor:ro
|
||||
depends_on:
|
||||
- tor
|
||||
networks:
|
||||
- localgreenchain-network
|
||||
restart: unless-stopped
|
||||
command: bun run start
|
||||
|
||||
# Optional: nginx reverse proxy for additional security
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: localgreenchain-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- localgreenchain-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
localgreenchain-network:
|
||||
driver: bridge
|
||||
106
lib/blockchain/PlantBlock.ts
Normal file
106
lib/blockchain/PlantBlock.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import crypto from 'crypto';
|
||||
import { PlantData, BlockData } from './types';
|
||||
|
||||
/**
|
||||
* PlantBlock - Represents a single block in the plant blockchain
|
||||
* Each block contains data about a plant and its lineage
|
||||
*/
|
||||
export class PlantBlock {
|
||||
public index: number;
|
||||
public timestamp: string;
|
||||
public plant: PlantData;
|
||||
public previousHash: string;
|
||||
public hash: string;
|
||||
public nonce: number;
|
||||
|
||||
constructor(
|
||||
index: number,
|
||||
timestamp: string,
|
||||
plant: PlantData,
|
||||
previousHash: string = ''
|
||||
) {
|
||||
this.index = index;
|
||||
this.timestamp = timestamp;
|
||||
this.plant = plant;
|
||||
this.previousHash = previousHash;
|
||||
this.nonce = 0;
|
||||
this.hash = this.calculateHash();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the hash of this block using SHA-256
|
||||
*/
|
||||
calculateHash(): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
this.index +
|
||||
this.previousHash +
|
||||
this.timestamp +
|
||||
JSON.stringify(this.plant) +
|
||||
this.nonce
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine the block using proof-of-work algorithm
|
||||
* Difficulty determines how many leading zeros the hash must have
|
||||
*/
|
||||
mineBlock(difficulty: number): void {
|
||||
const target = Array(difficulty + 1).join('0');
|
||||
|
||||
while (this.hash.substring(0, difficulty) !== target) {
|
||||
this.nonce++;
|
||||
this.hash = this.calculateHash();
|
||||
}
|
||||
|
||||
console.log(`Block mined: ${this.hash}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert block to JSON for storage/transmission
|
||||
*/
|
||||
toJSON(): BlockData {
|
||||
return {
|
||||
index: this.index,
|
||||
timestamp: this.timestamp,
|
||||
plant: this.plant,
|
||||
previousHash: this.previousHash,
|
||||
hash: this.hash,
|
||||
nonce: this.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PlantBlock from JSON data
|
||||
*/
|
||||
static fromJSON(data: BlockData): PlantBlock {
|
||||
const block = new PlantBlock(
|
||||
data.index,
|
||||
data.timestamp,
|
||||
data.plant,
|
||||
data.previousHash
|
||||
);
|
||||
block.hash = data.hash;
|
||||
block.nonce = data.nonce;
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if this block is valid
|
||||
*/
|
||||
isValid(previousBlock?: PlantBlock): boolean {
|
||||
// Check if hash is correct
|
||||
if (this.hash !== this.calculateHash()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if previous hash matches
|
||||
if (previousBlock && this.previousHash !== previousBlock.hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
396
lib/blockchain/PlantChain.ts
Normal file
396
lib/blockchain/PlantChain.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { PlantBlock } from './PlantBlock';
|
||||
import { PlantData, PlantLineage, NearbyPlant, PlantNetwork } from './types';
|
||||
|
||||
/**
|
||||
* PlantChain - The main blockchain for tracking plant lineage and ownership
|
||||
* This blockchain records every plant, its clones, seeds, and ownership transfers
|
||||
*/
|
||||
export class PlantChain {
|
||||
public chain: PlantBlock[];
|
||||
public difficulty: number;
|
||||
private plantIndex: Map<string, PlantBlock>; // Quick lookup by plant ID
|
||||
|
||||
constructor(difficulty: number = 4) {
|
||||
this.chain = [this.createGenesisBlock()];
|
||||
this.difficulty = difficulty;
|
||||
this.plantIndex = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the first block in the chain
|
||||
*/
|
||||
private createGenesisBlock(): PlantBlock {
|
||||
const genesisPlant: PlantData = {
|
||||
id: 'genesis-plant-0',
|
||||
commonName: 'Genesis Plant',
|
||||
scientificName: 'Blockchain primordialis',
|
||||
propagationType: 'original',
|
||||
generation: 0,
|
||||
plantedDate: new Date().toISOString(),
|
||||
status: 'mature',
|
||||
location: {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
address: 'The Beginning',
|
||||
},
|
||||
owner: {
|
||||
id: 'system',
|
||||
name: 'LocalGreenChain',
|
||||
email: 'system@localgreenchain.org',
|
||||
},
|
||||
childPlants: [],
|
||||
registeredAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const block = new PlantBlock(0, new Date().toISOString(), genesisPlant, '0');
|
||||
this.plantIndex.set(genesisPlant.id, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest block in the chain
|
||||
*/
|
||||
getLatestBlock(): PlantBlock {
|
||||
return this.chain[this.chain.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new original plant (not a clone or seed)
|
||||
*/
|
||||
registerPlant(plant: PlantData): PlantBlock {
|
||||
// Ensure plant has required fields
|
||||
if (!plant.id || !plant.commonName || !plant.owner) {
|
||||
throw new Error('Plant must have id, commonName, and owner');
|
||||
}
|
||||
|
||||
// Check if plant already exists
|
||||
if (this.plantIndex.has(plant.id)) {
|
||||
throw new Error(`Plant with ID ${plant.id} already exists`);
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
plant.generation = 0;
|
||||
plant.propagationType = plant.propagationType || 'original';
|
||||
plant.childPlants = [];
|
||||
plant.registeredAt = new Date().toISOString();
|
||||
plant.updatedAt = new Date().toISOString();
|
||||
|
||||
const newBlock = new PlantBlock(
|
||||
this.chain.length,
|
||||
new Date().toISOString(),
|
||||
plant,
|
||||
this.getLatestBlock().hash
|
||||
);
|
||||
|
||||
newBlock.mineBlock(this.difficulty);
|
||||
this.chain.push(newBlock);
|
||||
this.plantIndex.set(plant.id, newBlock);
|
||||
|
||||
return newBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a clone or seed from an existing plant
|
||||
*/
|
||||
clonePlant(
|
||||
parentPlantId: string,
|
||||
newPlant: Partial<PlantData>,
|
||||
propagationType: 'seed' | 'clone' | 'cutting' | 'division' | 'grafting'
|
||||
): PlantBlock {
|
||||
// Find parent plant
|
||||
const parentBlock = this.plantIndex.get(parentPlantId);
|
||||
if (!parentBlock) {
|
||||
throw new Error(`Parent plant ${parentPlantId} not found`);
|
||||
}
|
||||
|
||||
const parentPlant = parentBlock.plant;
|
||||
|
||||
// Create new plant with inherited properties
|
||||
const clonedPlant: PlantData = {
|
||||
id: newPlant.id || `${parentPlantId}-${propagationType}-${Date.now()}`,
|
||||
commonName: newPlant.commonName || parentPlant.commonName,
|
||||
scientificName: newPlant.scientificName || parentPlant.scientificName,
|
||||
species: newPlant.species || parentPlant.species,
|
||||
genus: newPlant.genus || parentPlant.genus,
|
||||
family: newPlant.family || parentPlant.family,
|
||||
|
||||
// Lineage
|
||||
parentPlantId: parentPlantId,
|
||||
propagationType: propagationType,
|
||||
generation: parentPlant.generation + 1,
|
||||
|
||||
// Required fields from newPlant
|
||||
plantedDate: newPlant.plantedDate || new Date().toISOString(),
|
||||
status: newPlant.status || 'sprouted',
|
||||
location: newPlant.location!,
|
||||
owner: newPlant.owner!,
|
||||
|
||||
// Initialize child tracking
|
||||
childPlants: [],
|
||||
|
||||
// Optional fields
|
||||
notes: newPlant.notes,
|
||||
images: newPlant.images,
|
||||
plantsNetId: newPlant.plantsNetId,
|
||||
|
||||
registeredAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!clonedPlant.location || !clonedPlant.owner) {
|
||||
throw new Error('Cloned plant must have location and owner');
|
||||
}
|
||||
|
||||
// Add to blockchain
|
||||
const newBlock = new PlantBlock(
|
||||
this.chain.length,
|
||||
new Date().toISOString(),
|
||||
clonedPlant,
|
||||
this.getLatestBlock().hash
|
||||
);
|
||||
|
||||
newBlock.mineBlock(this.difficulty);
|
||||
this.chain.push(newBlock);
|
||||
this.plantIndex.set(clonedPlant.id, newBlock);
|
||||
|
||||
// Update parent plant's child list
|
||||
parentPlant.childPlants.push(clonedPlant.id);
|
||||
parentPlant.updatedAt = new Date().toISOString();
|
||||
|
||||
return newBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plant status (growing, flowering, etc.)
|
||||
*/
|
||||
updatePlantStatus(
|
||||
plantId: string,
|
||||
updates: Partial<PlantData>
|
||||
): PlantBlock {
|
||||
const existingBlock = this.plantIndex.get(plantId);
|
||||
if (!existingBlock) {
|
||||
throw new Error(`Plant ${plantId} not found`);
|
||||
}
|
||||
|
||||
const updatedPlant: PlantData = {
|
||||
...existingBlock.plant,
|
||||
...updates,
|
||||
id: existingBlock.plant.id, // Cannot change ID
|
||||
parentPlantId: existingBlock.plant.parentPlantId, // Cannot change lineage
|
||||
childPlants: existingBlock.plant.childPlants, // Cannot change children
|
||||
generation: existingBlock.plant.generation, // Cannot change generation
|
||||
registeredAt: existingBlock.plant.registeredAt, // Cannot change registration
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const newBlock = new PlantBlock(
|
||||
this.chain.length,
|
||||
new Date().toISOString(),
|
||||
updatedPlant,
|
||||
this.getLatestBlock().hash
|
||||
);
|
||||
|
||||
newBlock.mineBlock(this.difficulty);
|
||||
this.chain.push(newBlock);
|
||||
this.plantIndex.set(plantId, newBlock); // Update index to latest block
|
||||
|
||||
return newBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plant by ID (returns latest version)
|
||||
*/
|
||||
getPlant(plantId: string): PlantData | null {
|
||||
const block = this.plantIndex.get(plantId);
|
||||
return block ? block.plant : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete lineage for a plant
|
||||
*/
|
||||
getPlantLineage(plantId: string): PlantLineage | null {
|
||||
const plant = this.getPlant(plantId);
|
||||
if (!plant) return null;
|
||||
|
||||
// Get all ancestors
|
||||
const ancestors: PlantData[] = [];
|
||||
let currentPlant = plant;
|
||||
while (currentPlant.parentPlantId) {
|
||||
const parent = this.getPlant(currentPlant.parentPlantId);
|
||||
if (!parent) break;
|
||||
ancestors.push(parent);
|
||||
currentPlant = parent;
|
||||
}
|
||||
|
||||
// Get all descendants recursively
|
||||
const descendants: PlantData[] = [];
|
||||
const getDescendants = (p: PlantData) => {
|
||||
for (const childId of p.childPlants) {
|
||||
const child = this.getPlant(childId);
|
||||
if (child) {
|
||||
descendants.push(child);
|
||||
getDescendants(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
getDescendants(plant);
|
||||
|
||||
// Get siblings (other plants from same parent)
|
||||
const siblings: PlantData[] = [];
|
||||
if (plant.parentPlantId) {
|
||||
const parent = this.getPlant(plant.parentPlantId);
|
||||
if (parent) {
|
||||
for (const siblingId of parent.childPlants) {
|
||||
if (siblingId !== plantId) {
|
||||
const sibling = this.getPlant(siblingId);
|
||||
if (sibling) siblings.push(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plantId,
|
||||
ancestors,
|
||||
descendants,
|
||||
siblings,
|
||||
generation: plant.generation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plants near a location (within radius in km)
|
||||
*/
|
||||
findNearbyPlants(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusKm: number = 50
|
||||
): NearbyPlant[] {
|
||||
const nearbyPlants: NearbyPlant[] = [];
|
||||
|
||||
// Get all unique plants (latest version of each)
|
||||
const allPlants = Array.from(this.plantIndex.values()).map(block => block.plant);
|
||||
|
||||
for (const plant of allPlants) {
|
||||
const distance = this.calculateDistance(
|
||||
latitude,
|
||||
longitude,
|
||||
plant.location.latitude,
|
||||
plant.location.longitude
|
||||
);
|
||||
|
||||
if (distance <= radiusKm) {
|
||||
nearbyPlants.push({
|
||||
plant,
|
||||
distance,
|
||||
owner: plant.owner,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance
|
||||
return nearbyPlants.sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
*/
|
||||
private calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRadians(lat2 - lat1);
|
||||
const dLon = this.toRadians(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRadians(lat1)) *
|
||||
Math.cos(this.toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
private toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network statistics
|
||||
*/
|
||||
getNetworkStats(): PlantNetwork {
|
||||
const allPlants = Array.from(this.plantIndex.values()).map(block => block.plant);
|
||||
const owners = new Set<string>();
|
||||
const species: { [key: string]: number } = {};
|
||||
const countries: { [key: string]: number } = {};
|
||||
|
||||
for (const plant of allPlants) {
|
||||
owners.add(plant.owner.id);
|
||||
|
||||
if (plant.scientificName) {
|
||||
species[plant.scientificName] = (species[plant.scientificName] || 0) + 1;
|
||||
}
|
||||
|
||||
if (plant.location.country) {
|
||||
countries[plant.location.country] = (countries[plant.location.country] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalPlants: allPlants.length,
|
||||
totalOwners: owners.size,
|
||||
species,
|
||||
globalDistribution: countries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the entire blockchain
|
||||
*/
|
||||
isChainValid(): boolean {
|
||||
for (let i = 1; i < this.chain.length; i++) {
|
||||
const currentBlock = this.chain[i];
|
||||
const previousBlock = this.chain[i - 1];
|
||||
|
||||
if (!currentBlock.isValid(previousBlock)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export chain to JSON
|
||||
*/
|
||||
toJSON(): any {
|
||||
return {
|
||||
difficulty: this.difficulty,
|
||||
chain: this.chain.map(block => block.toJSON()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import chain from JSON
|
||||
*/
|
||||
static fromJSON(data: any): PlantChain {
|
||||
const plantChain = new PlantChain(data.difficulty);
|
||||
plantChain.chain = data.chain.map((blockData: any) =>
|
||||
PlantBlock.fromJSON(blockData)
|
||||
);
|
||||
|
||||
// Rebuild index
|
||||
plantChain.plantIndex.clear();
|
||||
for (const block of plantChain.chain) {
|
||||
plantChain.plantIndex.set(block.plant.id, block);
|
||||
}
|
||||
|
||||
return plantChain;
|
||||
}
|
||||
}
|
||||
106
lib/blockchain/manager.ts
Normal file
106
lib/blockchain/manager.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Blockchain Manager
|
||||
* Singleton to manage the global plant blockchain instance
|
||||
*/
|
||||
|
||||
import { PlantChain } from './PlantChain';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const BLOCKCHAIN_FILE = path.join(process.cwd(), 'data', 'plantchain.json');
|
||||
|
||||
class BlockchainManager {
|
||||
private static instance: BlockchainManager;
|
||||
private plantChain: PlantChain;
|
||||
private autoSaveInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.plantChain = this.loadBlockchain();
|
||||
this.startAutoSave();
|
||||
}
|
||||
|
||||
public static getInstance(): BlockchainManager {
|
||||
if (!BlockchainManager.instance) {
|
||||
BlockchainManager.instance = new BlockchainManager();
|
||||
}
|
||||
return BlockchainManager.instance;
|
||||
}
|
||||
|
||||
public getChain(): PlantChain {
|
||||
return this.plantChain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load blockchain from file or create new one
|
||||
*/
|
||||
private loadBlockchain(): PlantChain {
|
||||
try {
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(BLOCKCHAIN_FILE)) {
|
||||
const data = fs.readFileSync(BLOCKCHAIN_FILE, 'utf-8');
|
||||
const chainData = JSON.parse(data);
|
||||
console.log('✓ Loaded existing blockchain with', chainData.chain.length, 'blocks');
|
||||
return PlantChain.fromJSON(chainData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading blockchain:', error);
|
||||
}
|
||||
|
||||
console.log('✓ Created new blockchain');
|
||||
return new PlantChain(4); // difficulty of 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Save blockchain to file
|
||||
*/
|
||||
public saveBlockchain(): void {
|
||||
try {
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const data = JSON.stringify(this.plantChain.toJSON(), null, 2);
|
||||
fs.writeFileSync(BLOCKCHAIN_FILE, data, 'utf-8');
|
||||
console.log('✓ Blockchain saved');
|
||||
} catch (error) {
|
||||
console.error('Error saving blockchain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save blockchain every 5 minutes
|
||||
*/
|
||||
private startAutoSave(): void {
|
||||
if (this.autoSaveInterval) {
|
||||
clearInterval(this.autoSaveInterval);
|
||||
}
|
||||
|
||||
this.autoSaveInterval = setInterval(() => {
|
||||
this.saveBlockchain();
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-save
|
||||
*/
|
||||
public stopAutoSave(): void {
|
||||
if (this.autoSaveInterval) {
|
||||
clearInterval(this.autoSaveInterval);
|
||||
this.autoSaveInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBlockchain(): PlantChain {
|
||||
return BlockchainManager.getInstance().getChain();
|
||||
}
|
||||
|
||||
export function saveBlockchain(): void {
|
||||
BlockchainManager.getInstance().saveBlockchain();
|
||||
}
|
||||
87
lib/blockchain/types.ts
Normal file
87
lib/blockchain/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Plant Blockchain Types
|
||||
|
||||
import { GrowingEnvironment, GrowthMetrics } from '../environment/types';
|
||||
|
||||
export interface PlantLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface PlantOwner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
walletAddress?: string;
|
||||
}
|
||||
|
||||
export interface PlantData {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName?: string;
|
||||
species?: string;
|
||||
genus?: string;
|
||||
family?: string;
|
||||
|
||||
// Lineage tracking
|
||||
parentPlantId?: string; // Original plant this came from
|
||||
propagationType?: 'seed' | 'clone' | 'cutting' | 'division' | 'grafting' | 'original';
|
||||
generation: number; // How many generations from the original
|
||||
|
||||
// Plant lifecycle
|
||||
plantedDate: string;
|
||||
harvestedDate?: string;
|
||||
status: 'sprouted' | 'growing' | 'mature' | 'flowering' | 'fruiting' | 'dormant' | 'deceased';
|
||||
|
||||
// Location and ownership
|
||||
location: PlantLocation;
|
||||
owner: PlantOwner;
|
||||
|
||||
// Plant network
|
||||
childPlants: string[]; // IDs of clones and seeds from this plant
|
||||
|
||||
// Environmental data
|
||||
environment?: GrowingEnvironment;
|
||||
growthMetrics?: GrowthMetrics;
|
||||
|
||||
// Additional metadata
|
||||
notes?: string;
|
||||
images?: string[];
|
||||
plantsNetId?: string; // ID from plants.net API
|
||||
|
||||
// Timestamps
|
||||
registeredAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BlockData {
|
||||
index: number;
|
||||
timestamp: string;
|
||||
plant: PlantData;
|
||||
previousHash: string;
|
||||
hash: string;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export interface PlantLineage {
|
||||
plantId: string;
|
||||
ancestors: PlantData[];
|
||||
descendants: PlantData[];
|
||||
siblings: PlantData[]; // Other plants from the same parent
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface NearbyPlant {
|
||||
plant: PlantData;
|
||||
distance: number; // in kilometers
|
||||
owner: PlantOwner;
|
||||
}
|
||||
|
||||
export interface PlantNetwork {
|
||||
totalPlants: number;
|
||||
totalOwners: number;
|
||||
species: { [key: string]: number };
|
||||
globalDistribution: { [country: string]: number };
|
||||
}
|
||||
432
lib/environment/analysis.ts
Normal file
432
lib/environment/analysis.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Environmental Analysis Service
|
||||
* Compares growing conditions, generates recommendations, and analyzes correlations
|
||||
*/
|
||||
|
||||
import {
|
||||
GrowingEnvironment,
|
||||
EnvironmentalComparison,
|
||||
EnvironmentalRecommendation,
|
||||
SoilComposition,
|
||||
ClimateConditions,
|
||||
LightingConditions,
|
||||
NutrientProfile,
|
||||
} from './types';
|
||||
import { PlantData } from '../blockchain/types';
|
||||
|
||||
/**
|
||||
* Compare two growing environments and return similarity score
|
||||
*/
|
||||
export function compareEnvironments(
|
||||
env1: GrowingEnvironment,
|
||||
env2: GrowingEnvironment
|
||||
): EnvironmentalComparison {
|
||||
const similarities: string[] = [];
|
||||
const differences: string[] = [];
|
||||
let score = 0;
|
||||
let maxScore = 0;
|
||||
|
||||
// Compare location type (10 points)
|
||||
maxScore += 10;
|
||||
if (env1.location.type === env2.location.type) {
|
||||
score += 10;
|
||||
similarities.push(`Both ${env1.location.type} growing`);
|
||||
} else {
|
||||
differences.push(`Location: ${env1.location.type} vs ${env2.location.type}`);
|
||||
}
|
||||
|
||||
// Compare soil type (15 points)
|
||||
maxScore += 15;
|
||||
if (env1.soil.type === env2.soil.type) {
|
||||
score += 15;
|
||||
similarities.push(`Same soil type: ${env1.soil.type}`);
|
||||
} else {
|
||||
differences.push(`Soil: ${env1.soil.type} vs ${env2.soil.type}`);
|
||||
}
|
||||
|
||||
// Compare soil pH (10 points, within 0.5 range)
|
||||
maxScore += 10;
|
||||
const pHDiff = Math.abs(env1.soil.pH - env2.soil.pH);
|
||||
if (pHDiff <= 0.5) {
|
||||
score += 10;
|
||||
similarities.push(`Similar soil pH: ${env1.soil.pH.toFixed(1)} ≈ ${env2.soil.pH.toFixed(1)}`);
|
||||
} else if (pHDiff <= 1.0) {
|
||||
score += 5;
|
||||
differences.push(`Soil pH: ${env1.soil.pH.toFixed(1)} vs ${env2.soil.pH.toFixed(1)}`);
|
||||
} else {
|
||||
differences.push(`Soil pH differs significantly: ${env1.soil.pH.toFixed(1)} vs ${env2.soil.pH.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// Compare temperature (15 points)
|
||||
maxScore += 15;
|
||||
const tempDiff = Math.abs(env1.climate.temperatureDay - env2.climate.temperatureDay);
|
||||
if (tempDiff <= 3) {
|
||||
score += 15;
|
||||
similarities.push(`Similar temperatures: ${env1.climate.temperatureDay}°C ≈ ${env2.climate.temperatureDay}°C`);
|
||||
} else if (tempDiff <= 7) {
|
||||
score += 8;
|
||||
differences.push(`Temperature: ${env1.climate.temperatureDay}°C vs ${env2.climate.temperatureDay}°C`);
|
||||
} else {
|
||||
differences.push(`Temperature differs significantly: ${env1.climate.temperatureDay}°C vs ${env2.climate.temperatureDay}°C`);
|
||||
}
|
||||
|
||||
// Compare humidity (10 points)
|
||||
maxScore += 10;
|
||||
const humidityDiff = Math.abs(env1.climate.humidityAverage - env2.climate.humidityAverage);
|
||||
if (humidityDiff <= 10) {
|
||||
score += 10;
|
||||
similarities.push(`Similar humidity: ${env1.climate.humidityAverage}% ≈ ${env2.climate.humidityAverage}%`);
|
||||
} else if (humidityDiff <= 20) {
|
||||
score += 5;
|
||||
} else {
|
||||
differences.push(`Humidity: ${env1.climate.humidityAverage}% vs ${env2.climate.humidityAverage}%`);
|
||||
}
|
||||
|
||||
// Compare lighting type (15 points)
|
||||
maxScore += 15;
|
||||
if (env1.lighting.type === env2.lighting.type) {
|
||||
score += 15;
|
||||
similarities.push(`Same lighting: ${env1.lighting.type}`);
|
||||
} else {
|
||||
differences.push(`Lighting: ${env1.lighting.type} vs ${env2.lighting.type}`);
|
||||
}
|
||||
|
||||
// Compare natural light exposure if both use natural light (10 points)
|
||||
if (env1.lighting.naturalLight && env2.lighting.naturalLight) {
|
||||
maxScore += 10;
|
||||
if (env1.lighting.naturalLight.exposure === env2.lighting.naturalLight.exposure) {
|
||||
score += 10;
|
||||
similarities.push(`Same sun exposure: ${env1.lighting.naturalLight.exposure}`);
|
||||
} else {
|
||||
differences.push(`Sun exposure: ${env1.lighting.naturalLight.exposure} vs ${env2.lighting.naturalLight.exposure}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare water source (5 points)
|
||||
maxScore += 5;
|
||||
if (env1.watering.waterSource === env2.watering.waterSource) {
|
||||
score += 5;
|
||||
similarities.push(`Same water source: ${env1.watering.waterSource}`);
|
||||
} else {
|
||||
differences.push(`Water: ${env1.watering.waterSource} vs ${env2.watering.waterSource}`);
|
||||
}
|
||||
|
||||
// Compare container type (10 points)
|
||||
if (env1.container && env2.container) {
|
||||
maxScore += 10;
|
||||
if (env1.container.type === env2.container.type) {
|
||||
score += 10;
|
||||
similarities.push(`Same container: ${env1.container.type}`);
|
||||
} else {
|
||||
differences.push(`Container: ${env1.container.type} vs ${env2.container.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final score as percentage
|
||||
const finalScore = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
|
||||
|
||||
return {
|
||||
plant1: '', // Will be filled by caller
|
||||
plant2: '', // Will be filled by caller
|
||||
similarities,
|
||||
differences,
|
||||
score: finalScore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate environmental recommendations based on plant data
|
||||
*/
|
||||
export function generateRecommendations(
|
||||
plant: PlantData
|
||||
): EnvironmentalRecommendation[] {
|
||||
const recommendations: EnvironmentalRecommendation[] = [];
|
||||
|
||||
if (!plant.environment) {
|
||||
recommendations.push({
|
||||
category: 'general',
|
||||
priority: 'high',
|
||||
issue: 'No environmental data',
|
||||
recommendation: 'Add environmental information to track growing conditions',
|
||||
impact: 'Environmental tracking helps optimize growing conditions and share knowledge',
|
||||
});
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
const env = plant.environment;
|
||||
|
||||
// Soil pH recommendations
|
||||
if (env.soil.pH < 5.5) {
|
||||
recommendations.push({
|
||||
category: 'soil',
|
||||
priority: 'high',
|
||||
issue: `Low soil pH (${env.soil.pH})`,
|
||||
recommendation: 'Add lime to raise pH. Most plants prefer 6.0-7.0',
|
||||
impact: 'Acidic soil can lock out nutrients and harm roots',
|
||||
});
|
||||
} else if (env.soil.pH > 7.5) {
|
||||
recommendations.push({
|
||||
category: 'soil',
|
||||
priority: 'high',
|
||||
issue: `High soil pH (${env.soil.pH})`,
|
||||
recommendation: 'Add sulfur to lower pH. Most plants prefer 6.0-7.0',
|
||||
impact: 'Alkaline soil can prevent nutrient uptake',
|
||||
});
|
||||
}
|
||||
|
||||
// Organic matter recommendations
|
||||
if (env.soil.organicMatter < 3) {
|
||||
recommendations.push({
|
||||
category: 'soil',
|
||||
priority: 'medium',
|
||||
issue: `Low organic matter (${env.soil.organicMatter}%)`,
|
||||
recommendation: 'Add compost or aged manure to improve soil structure',
|
||||
impact: 'Increases water retention and nutrient availability',
|
||||
});
|
||||
}
|
||||
|
||||
// Drainage recommendations
|
||||
if (env.soil.drainage === 'poor') {
|
||||
recommendations.push({
|
||||
category: 'soil',
|
||||
priority: 'high',
|
||||
issue: 'Poor soil drainage',
|
||||
recommendation: 'Add perlite, sand, or create raised beds',
|
||||
impact: 'Prevents root rot and improves oxygen availability',
|
||||
});
|
||||
}
|
||||
|
||||
// Temperature recommendations
|
||||
if (env.climate.temperatureDay > 35) {
|
||||
recommendations.push({
|
||||
category: 'climate',
|
||||
priority: 'high',
|
||||
issue: `High daytime temperature (${env.climate.temperatureDay}°C)`,
|
||||
recommendation: 'Provide shade during hottest hours, increase watering',
|
||||
impact: 'Extreme heat can cause stress and reduce growth',
|
||||
});
|
||||
} else if (env.climate.temperatureDay < 10) {
|
||||
recommendations.push({
|
||||
category: 'climate',
|
||||
priority: 'high',
|
||||
issue: `Low daytime temperature (${env.climate.temperatureDay}°C)`,
|
||||
recommendation: 'Provide frost protection or move indoors',
|
||||
impact: 'Cold temperatures can slow growth or damage plants',
|
||||
});
|
||||
}
|
||||
|
||||
// Humidity recommendations
|
||||
if (env.climate.humidityAverage < 30) {
|
||||
recommendations.push({
|
||||
category: 'climate',
|
||||
priority: 'medium',
|
||||
issue: `Low humidity (${env.climate.humidityAverage}%)`,
|
||||
recommendation: 'Mist plants, use humidity tray, or group plants together',
|
||||
impact: 'Low humidity can cause leaf tip browning and stress',
|
||||
});
|
||||
} else if (env.climate.humidityAverage > 80) {
|
||||
recommendations.push({
|
||||
category: 'climate',
|
||||
priority: 'medium',
|
||||
issue: `High humidity (${env.climate.humidityAverage}%)`,
|
||||
recommendation: 'Improve air circulation to prevent fungal issues',
|
||||
impact: 'High humidity can promote mold and disease',
|
||||
});
|
||||
}
|
||||
|
||||
// Lighting recommendations
|
||||
if (env.lighting.type === 'natural' && env.lighting.naturalLight) {
|
||||
const hoursPerDay = env.lighting.naturalLight.hoursPerDay;
|
||||
if (hoursPerDay < 4 && plant.status === 'growing') {
|
||||
recommendations.push({
|
||||
category: 'light',
|
||||
priority: 'high',
|
||||
issue: `Insufficient light (${hoursPerDay} hours/day)`,
|
||||
recommendation: 'Move to brighter location or supplement with grow lights',
|
||||
impact: 'Inadequate light causes leggy growth and poor health',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Nutrient recommendations
|
||||
if (env.nutrients) {
|
||||
const npk = [env.nutrients.nitrogen, env.nutrients.phosphorus, env.nutrients.potassium];
|
||||
if (npk.some(n => n < 1)) {
|
||||
recommendations.push({
|
||||
category: 'nutrients',
|
||||
priority: 'medium',
|
||||
issue: `Low NPK levels (${npk.join('-')})`,
|
||||
recommendation: 'Apply balanced fertilizer according to plant needs',
|
||||
impact: 'Nutrient deficiency reduces growth and yield',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Water quality recommendations
|
||||
if (env.watering.waterQuality) {
|
||||
if (env.watering.waterQuality.pH && env.watering.waterQuality.pH > 7.5) {
|
||||
recommendations.push({
|
||||
category: 'water',
|
||||
priority: 'low',
|
||||
issue: `Alkaline water pH (${env.watering.waterQuality.pH})`,
|
||||
recommendation: 'Consider pH adjustment or rainwater collection',
|
||||
impact: 'High water pH can gradually raise soil pH',
|
||||
});
|
||||
}
|
||||
if (env.watering.waterQuality.chlorine === 'yes') {
|
||||
recommendations.push({
|
||||
category: 'water',
|
||||
priority: 'low',
|
||||
issue: 'Chlorinated water',
|
||||
recommendation: 'Let water sit 24h before use or use filter',
|
||||
impact: 'Chlorine can harm beneficial soil microbes',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Container recommendations
|
||||
if (env.container && env.container.drainage === 'no') {
|
||||
recommendations.push({
|
||||
category: 'general',
|
||||
priority: 'critical',
|
||||
issue: 'Container has no drainage',
|
||||
recommendation: 'Add drainage holes immediately to prevent root rot',
|
||||
impact: 'Critical: Standing water will kill most plants',
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations.sort((a, b) => {
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plants with similar growing conditions
|
||||
*/
|
||||
export function findSimilarEnvironments(
|
||||
targetPlant: PlantData,
|
||||
allPlants: PlantData[],
|
||||
minScore: number = 70
|
||||
): Array<{ plant: PlantData; comparison: EnvironmentalComparison }> {
|
||||
if (!targetPlant.environment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const similar: Array<{ plant: PlantData; comparison: EnvironmentalComparison }> = [];
|
||||
|
||||
for (const plant of allPlants) {
|
||||
// Skip self and plants without environment data
|
||||
if (plant.id === targetPlant.id || !plant.environment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const comparison = compareEnvironments(targetPlant.environment, plant.environment);
|
||||
comparison.plant1 = targetPlant.id;
|
||||
comparison.plant2 = plant.id;
|
||||
|
||||
if (comparison.score >= minScore) {
|
||||
similar.push({ plant, comparison });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity score (highest first)
|
||||
return similar.sort((a, b) => b.comparison.score - a.comparison.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze growth success based on environmental factors
|
||||
*/
|
||||
export function analyzeGrowthCorrelation(plants: PlantData[]): {
|
||||
bestConditions: Partial<GrowingEnvironment>;
|
||||
insights: string[];
|
||||
} {
|
||||
const successfulPlants = plants.filter(
|
||||
p => p.environment && (p.status === 'mature' || p.status === 'flowering' || p.status === 'fruiting')
|
||||
);
|
||||
|
||||
if (successfulPlants.length === 0) {
|
||||
return {
|
||||
bestConditions: {},
|
||||
insights: ['Not enough data to determine optimal conditions'],
|
||||
};
|
||||
}
|
||||
|
||||
const insights: string[] = [];
|
||||
|
||||
// Analyze soil pH
|
||||
const pHValues = successfulPlants
|
||||
.filter(p => p.environment?.soil.pH)
|
||||
.map(p => p.environment!.soil.pH);
|
||||
|
||||
if (pHValues.length > 0) {
|
||||
const avgPH = pHValues.reduce((a, b) => a + b, 0) / pHValues.length;
|
||||
insights.push(`Successful plants average soil pH: ${avgPH.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// Analyze temperature
|
||||
const temps = successfulPlants
|
||||
.filter(p => p.environment?.climate.temperatureDay)
|
||||
.map(p => p.environment!.climate.temperatureDay);
|
||||
|
||||
if (temps.length > 0) {
|
||||
const avgTemp = temps.reduce((a, b) => a + b, 0) / temps.length;
|
||||
const minTemp = Math.min(...temps);
|
||||
const maxTemp = Math.max(...temps);
|
||||
insights.push(`Successful temperature range: ${minTemp}-${maxTemp}°C (avg: ${avgTemp.toFixed(1)}°C)`);
|
||||
}
|
||||
|
||||
// Analyze lighting
|
||||
const lightingTypes = successfulPlants
|
||||
.filter(p => p.environment?.lighting.type)
|
||||
.map(p => p.environment!.lighting.type);
|
||||
|
||||
const lightCount = lightingTypes.reduce((acc, type) => {
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const mostCommonLight = Object.entries(lightCount)
|
||||
.sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
if (mostCommonLight) {
|
||||
insights.push(`Most successful lighting: ${mostCommonLight[0]} (${mostCommonLight[1]} plants)`);
|
||||
}
|
||||
|
||||
return {
|
||||
bestConditions: {
|
||||
// Would be filled with actual aggregate data
|
||||
},
|
||||
insights,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate environmental health score (0-100)
|
||||
*/
|
||||
export function calculateEnvironmentalHealth(env: GrowingEnvironment): number {
|
||||
let score = 100;
|
||||
let deductions = 0;
|
||||
|
||||
// Soil health (up to -20 points)
|
||||
if (env.soil.pH < 5.5 || env.soil.pH > 7.5) deductions += 10;
|
||||
if (env.soil.drainage === 'poor') deductions += 10;
|
||||
if (env.soil.organicMatter < 3) deductions += 5;
|
||||
|
||||
// Climate (up to -20 points)
|
||||
if (env.climate.temperatureDay > 35 || env.climate.temperatureDay < 10) deductions += 10;
|
||||
if (env.climate.humidityAverage < 30 || env.climate.humidityAverage > 80) deductions += 5;
|
||||
if (env.climate.airflow === 'none') deductions += 5;
|
||||
|
||||
// Lighting (up to -15 points)
|
||||
if (env.lighting.naturalLight && env.lighting.naturalLight.hoursPerDay < 4) deductions += 15;
|
||||
|
||||
// Container (up to -15 points)
|
||||
if (env.container && env.container.drainage === 'no') deductions += 15;
|
||||
|
||||
// Water (up to -10 points)
|
||||
if (env.watering.waterQuality?.chlorine === 'yes') deductions += 5;
|
||||
if (env.watering.waterQuality?.pH && env.watering.waterQuality.pH > 7.5) deductions += 5;
|
||||
|
||||
return Math.max(0, score - deductions);
|
||||
}
|
||||
253
lib/environment/types.ts
Normal file
253
lib/environment/types.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Environmental Data Types for LocalGreenChain
|
||||
* Tracks soil, climate, nutrients, and growing conditions
|
||||
*/
|
||||
|
||||
// Soil Composition
|
||||
export interface SoilComposition {
|
||||
type: 'clay' | 'sand' | 'silt' | 'loam' | 'peat' | 'chalk' | 'custom';
|
||||
customType?: string;
|
||||
|
||||
// Soil properties
|
||||
pH: number; // 0-14, most plants 6.0-7.5
|
||||
texture: 'heavy' | 'medium' | 'light';
|
||||
drainage: 'poor' | 'moderate' | 'good' | 'excellent';
|
||||
organicMatter: number; // percentage 0-100
|
||||
|
||||
// Composition percentages (should sum to ~100)
|
||||
clayPercent?: number;
|
||||
sandPercent?: number;
|
||||
siltPercent?: number;
|
||||
|
||||
// Amendments/additives
|
||||
amendments?: SoilAmendment[];
|
||||
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SoilAmendment {
|
||||
type: 'compost' | 'manure' | 'perlite' | 'vermiculite' | 'peat_moss' | 'coco_coir' | 'biochar' | 'lime' | 'sulfur' | 'other';
|
||||
name: string;
|
||||
amount?: string; // e.g., "2 cups per gallon", "10%"
|
||||
dateAdded: string;
|
||||
}
|
||||
|
||||
// Nutrients & Fertilization
|
||||
export interface NutrientProfile {
|
||||
// NPK values (percentage)
|
||||
nitrogen: number; // N
|
||||
phosphorus: number; // P
|
||||
potassium: number; // K
|
||||
|
||||
// Secondary nutrients
|
||||
calcium?: number;
|
||||
magnesium?: number;
|
||||
sulfur?: number;
|
||||
|
||||
// Micronutrients (ppm or mg/L)
|
||||
iron?: number;
|
||||
manganese?: number;
|
||||
zinc?: number;
|
||||
copper?: number;
|
||||
boron?: number;
|
||||
molybdenum?: number;
|
||||
|
||||
// EC (Electrical Conductivity) - measure of nutrient concentration
|
||||
ec?: number; // mS/cm
|
||||
|
||||
// TDS (Total Dissolved Solids)
|
||||
tds?: number; // ppm
|
||||
|
||||
lastTested?: string;
|
||||
}
|
||||
|
||||
export interface FertilizerApplication {
|
||||
id: string;
|
||||
date: string;
|
||||
type: 'organic' | 'synthetic' | 'liquid' | 'granular' | 'foliar' | 'slow_release';
|
||||
name: string;
|
||||
npk?: string; // e.g., "10-10-10", "5-2-3"
|
||||
amount: string;
|
||||
frequency?: string; // e.g., "weekly", "bi-weekly", "monthly"
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Sunlight & Climate
|
||||
export interface LightingConditions {
|
||||
type: 'natural' | 'artificial' | 'mixed';
|
||||
|
||||
// Natural light
|
||||
naturalLight?: {
|
||||
exposure: 'full_sun' | 'partial_sun' | 'partial_shade' | 'full_shade';
|
||||
hoursPerDay: number; // 0-24
|
||||
direction: 'north' | 'south' | 'east' | 'west' | 'multiple';
|
||||
quality: 'direct' | 'filtered' | 'dappled' | 'indirect';
|
||||
};
|
||||
|
||||
// Artificial light
|
||||
artificialLight?: {
|
||||
type: 'LED' | 'fluorescent' | 'HPS' | 'MH' | 'incandescent' | 'mixed';
|
||||
spectrum?: string; // e.g., "full spectrum", "6500K", "2700K"
|
||||
wattage?: number;
|
||||
hoursPerDay: number;
|
||||
distance?: number; // cm from plant
|
||||
};
|
||||
|
||||
// Light measurements
|
||||
ppfd?: number; // Photosynthetic Photon Flux Density (μmol/m²/s)
|
||||
dli?: number; // Daily Light Integral (mol/m²/day)
|
||||
}
|
||||
|
||||
export interface ClimateConditions {
|
||||
// Temperature (Celsius)
|
||||
temperatureDay: number;
|
||||
temperatureNight: number;
|
||||
temperatureMin?: number;
|
||||
temperatureMax?: number;
|
||||
|
||||
// Humidity (percentage)
|
||||
humidityAverage: number;
|
||||
humidityMin?: number;
|
||||
humidityMax?: number;
|
||||
|
||||
// Air circulation
|
||||
airflow: 'none' | 'minimal' | 'moderate' | 'strong';
|
||||
ventilation: 'poor' | 'adequate' | 'good' | 'excellent';
|
||||
|
||||
// CO2 levels (ppm)
|
||||
co2?: number; // ambient ~400, enhanced ~1200-1500
|
||||
|
||||
// Seasonal variation
|
||||
season?: 'spring' | 'summer' | 'fall' | 'winter';
|
||||
zone?: string; // USDA hardiness zone, e.g., "9b", "10a"
|
||||
}
|
||||
|
||||
// Growing Environment
|
||||
export interface GrowingEnvironment {
|
||||
location: EnvironmentLocation;
|
||||
container?: ContainerInfo;
|
||||
soil: SoilComposition;
|
||||
nutrients: NutrientProfile;
|
||||
fertilizers?: FertilizerApplication[];
|
||||
lighting: LightingConditions;
|
||||
climate: ClimateConditions;
|
||||
watering: WateringSchedule;
|
||||
surroundings?: SurroundingEnvironment;
|
||||
|
||||
// Tracking
|
||||
monitoringFrequency?: 'daily' | 'weekly' | 'bi-weekly' | 'monthly';
|
||||
lastUpdated: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentLocation {
|
||||
type: 'indoor' | 'outdoor' | 'greenhouse' | 'polytunnel' | 'shade_house' | 'window' | 'balcony';
|
||||
description?: string;
|
||||
|
||||
// For indoor
|
||||
room?: string; // e.g., "bedroom", "basement", "grow tent"
|
||||
|
||||
// For outdoor
|
||||
exposureToElements?: 'protected' | 'semi_protected' | 'exposed';
|
||||
elevation?: number; // meters above sea level
|
||||
slope?: 'flat' | 'gentle' | 'moderate' | 'steep';
|
||||
aspect?: 'north' | 'south' | 'east' | 'west'; // slope direction
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
type: 'pot' | 'raised_bed' | 'ground' | 'hydroponic' | 'aeroponic' | 'aquaponic' | 'fabric_pot' | 'hanging_basket';
|
||||
material?: 'plastic' | 'terracotta' | 'ceramic' | 'fabric' | 'wood' | 'metal' | 'concrete';
|
||||
size?: string; // e.g., "5 gallon", "30cm diameter", "4x8 feet"
|
||||
volume?: number; // liters
|
||||
depth?: number; // cm
|
||||
drainage: 'yes' | 'no';
|
||||
drainageHoles?: number;
|
||||
}
|
||||
|
||||
export interface WateringSchedule {
|
||||
method: 'hand_water' | 'drip' | 'soaker_hose' | 'sprinkler' | 'self_watering' | 'hydroponic' | 'rain';
|
||||
frequency?: string; // e.g., "daily", "every 2-3 days", "weekly"
|
||||
amount?: string; // e.g., "1 liter", "until runoff", "1 inch"
|
||||
waterSource: 'tap' | 'well' | 'rain' | 'filtered' | 'distilled' | 'RO';
|
||||
waterQuality?: {
|
||||
pH?: number;
|
||||
tds?: number; // ppm
|
||||
chlorine?: 'yes' | 'no' | 'filtered';
|
||||
};
|
||||
}
|
||||
|
||||
export interface SurroundingEnvironment {
|
||||
// Companion plants
|
||||
companionPlants?: string[]; // other plant species nearby
|
||||
|
||||
// Nearby features
|
||||
nearbyTrees?: boolean;
|
||||
nearbyStructures?: string; // e.g., "building", "wall", "fence"
|
||||
groundCover?: string; // e.g., "mulch", "grass", "bare soil", "gravel"
|
||||
|
||||
// Wildlife & pests
|
||||
pollinators?: string[]; // e.g., "bees", "butterflies", "hummingbirds"
|
||||
beneficialInsects?: string[]; // e.g., "ladybugs", "lacewings"
|
||||
pests?: PestInfo[];
|
||||
diseases?: DiseaseInfo[];
|
||||
|
||||
// Microclimate factors
|
||||
windExposure?: 'sheltered' | 'moderate' | 'exposed' | 'windy';
|
||||
frostPocket?: boolean;
|
||||
heatTrap?: boolean;
|
||||
|
||||
// Ecosystem type
|
||||
ecosystem?: 'urban' | 'suburban' | 'rural' | 'forest' | 'desert' | 'coastal' | 'mountain' | 'tropical';
|
||||
}
|
||||
|
||||
export interface PestInfo {
|
||||
name: string;
|
||||
severity: 'minor' | 'moderate' | 'severe';
|
||||
treatment?: string;
|
||||
dateObserved: string;
|
||||
}
|
||||
|
||||
export interface DiseaseInfo {
|
||||
name: string;
|
||||
symptoms?: string;
|
||||
severity: 'minor' | 'moderate' | 'severe';
|
||||
treatment?: string;
|
||||
dateObserved: string;
|
||||
}
|
||||
|
||||
// Environmental Comparison & Analysis
|
||||
export interface EnvironmentalComparison {
|
||||
plant1: string; // plant ID
|
||||
plant2: string; // plant ID
|
||||
similarities: string[];
|
||||
differences: string[];
|
||||
score: number; // 0-100, how similar the environments are
|
||||
}
|
||||
|
||||
export interface GrowthMetrics {
|
||||
plantId: string;
|
||||
measurements: PlantMeasurement[];
|
||||
healthScore: number; // 0-100
|
||||
vigor: 'poor' | 'fair' | 'good' | 'excellent';
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
export interface PlantMeasurement {
|
||||
date: string;
|
||||
height?: number; // cm
|
||||
width?: number; // cm
|
||||
leafCount?: number;
|
||||
flowerCount?: number;
|
||||
fruitCount?: number;
|
||||
notes?: string;
|
||||
photos?: string[];
|
||||
}
|
||||
|
||||
// Helper types for environmental recommendations
|
||||
export interface EnvironmentalRecommendation {
|
||||
category: 'soil' | 'nutrients' | 'light' | 'water' | 'climate' | 'general';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
issue: string;
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}
|
||||
221
lib/privacy/anonymity.ts
Normal file
221
lib/privacy/anonymity.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Privacy and Anonymity Utilities for LocalGreenChain
|
||||
* Provides tools for anonymous plant tracking while maintaining blockchain integrity
|
||||
*/
|
||||
|
||||
export interface PrivacySettings {
|
||||
anonymousMode: boolean;
|
||||
locationPrivacy: 'exact' | 'fuzzy' | 'city' | 'country' | 'hidden';
|
||||
identityPrivacy: 'real' | 'pseudonym' | 'anonymous';
|
||||
sharePlantDetails: boolean;
|
||||
}
|
||||
|
||||
export interface FuzzyLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number; // radius in km
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate anonymous user ID using cryptographic hash
|
||||
*/
|
||||
export function generateAnonymousId(): string {
|
||||
const randomBytes = crypto.randomBytes(32);
|
||||
return 'anon_' + crypto.createHash('sha256').update(randomBytes).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pseudonymous wallet address
|
||||
*/
|
||||
export function generateWalletAddress(): string {
|
||||
const randomBytes = crypto.randomBytes(20);
|
||||
return '0x' + randomBytes.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obfuscate location based on privacy level
|
||||
* This prevents exact home address tracking while allowing geographic discovery
|
||||
*/
|
||||
export function obfuscateLocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
privacyLevel: 'exact' | 'fuzzy' | 'city' | 'country' | 'hidden'
|
||||
): FuzzyLocation {
|
||||
switch (privacyLevel) {
|
||||
case 'exact':
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: 0.1, // ~100m
|
||||
displayName: 'Exact location',
|
||||
};
|
||||
|
||||
case 'fuzzy':
|
||||
// Add random offset within 1-5km radius
|
||||
const fuzzRadius = 1 + Math.random() * 4; // 1-5 km
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const latOffset = (fuzzRadius / 111) * Math.cos(angle); // 111 km per degree
|
||||
const lonOffset = (fuzzRadius / (111 * Math.cos(latitude * Math.PI / 180))) * Math.sin(angle);
|
||||
|
||||
return {
|
||||
latitude: latitude + latOffset,
|
||||
longitude: longitude + lonOffset,
|
||||
accuracy: fuzzRadius,
|
||||
displayName: `Within ${Math.round(fuzzRadius)} km`,
|
||||
};
|
||||
|
||||
case 'city':
|
||||
// Round to ~10km grid (0.1 degree ≈ 11km)
|
||||
return {
|
||||
latitude: Math.round(latitude * 10) / 10,
|
||||
longitude: Math.round(longitude * 10) / 10,
|
||||
accuracy: 10,
|
||||
displayName: 'City area',
|
||||
};
|
||||
|
||||
case 'country':
|
||||
// Round to ~100km grid (1 degree ≈ 111km)
|
||||
return {
|
||||
latitude: Math.round(latitude),
|
||||
longitude: Math.round(longitude),
|
||||
accuracy: 100,
|
||||
displayName: 'Country/Region',
|
||||
};
|
||||
|
||||
case 'hidden':
|
||||
return {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
accuracy: 999999,
|
||||
displayName: 'Location hidden',
|
||||
};
|
||||
|
||||
default:
|
||||
return obfuscateLocation(latitude, longitude, 'fuzzy');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate anonymous plant name
|
||||
*/
|
||||
export function generateAnonymousPlantName(plantType: string, generation: number): string {
|
||||
const hash = crypto.randomBytes(4).toString('hex');
|
||||
return `${plantType}-Gen${generation}-${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data for storage
|
||||
*/
|
||||
export function encryptData(data: string, key: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const keyHash = crypto.createHash('sha256').update(key).digest();
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, keyHash, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive data
|
||||
*/
|
||||
export function decryptData(encryptedData: string, key: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const keyHash = crypto.createHash('sha256').update(key).digest();
|
||||
|
||||
const parts = encryptedData.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, keyHash, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Tor-friendly onion address from plant ID
|
||||
* This creates a deterministic onion-style identifier
|
||||
*/
|
||||
export function generateOnionIdentifier(plantId: string): string {
|
||||
const hash = crypto.createHash('sha256').update(plantId).digest('hex');
|
||||
return hash.substring(0, 16) + '.onion';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous contact method
|
||||
*/
|
||||
export function createAnonymousContact(realEmail: string, privateKey: string): string {
|
||||
// Hash email with private key to create anonymous identifier
|
||||
const hash = crypto.createHash('sha256')
|
||||
.update(realEmail + privateKey)
|
||||
.digest('hex');
|
||||
return `anon-${hash.substring(0, 12)}@localgreenchain.onion`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Tor connection
|
||||
*/
|
||||
export async function isTorConnection(req: any): Promise<boolean> {
|
||||
// Check if request is coming through Tor
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
|
||||
// Tor exit nodes typically set specific headers
|
||||
// This is a simplified check
|
||||
return (
|
||||
req.headers['x-tor-connection'] === 'true' ||
|
||||
req.socket?.remoteAddress?.includes('127.0.0.1') // Tor proxy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate privacy report for plant
|
||||
*/
|
||||
export interface PrivacyReport {
|
||||
locationPrivacy: string;
|
||||
identityPrivacy: string;
|
||||
dataEncryption: boolean;
|
||||
torEnabled: boolean;
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export function generatePrivacyReport(settings: PrivacySettings): PrivacyReport {
|
||||
const recommendations: string[] = [];
|
||||
let riskLevel: 'low' | 'medium' | 'high' = 'low';
|
||||
|
||||
if (settings.locationPrivacy === 'exact') {
|
||||
recommendations.push('Consider using fuzzy location to protect your privacy');
|
||||
riskLevel = 'high';
|
||||
}
|
||||
|
||||
if (settings.identityPrivacy === 'real') {
|
||||
recommendations.push('Using real identity may compromise anonymity');
|
||||
if (riskLevel === 'low') riskLevel = 'medium';
|
||||
}
|
||||
|
||||
if (settings.sharePlantDetails && !settings.anonymousMode) {
|
||||
recommendations.push('Sharing detailed plant info without anonymous mode enabled');
|
||||
}
|
||||
|
||||
if (settings.anonymousMode && settings.locationPrivacy !== 'hidden') {
|
||||
riskLevel = 'low';
|
||||
recommendations.push('Good privacy settings enabled');
|
||||
}
|
||||
|
||||
return {
|
||||
locationPrivacy: settings.locationPrivacy,
|
||||
identityPrivacy: settings.identityPrivacy,
|
||||
dataEncryption: settings.anonymousMode,
|
||||
torEnabled: false, // Will be detected at runtime
|
||||
riskLevel,
|
||||
recommendations: recommendations.length > 0 ? recommendations : ['Privacy settings are optimal'],
|
||||
};
|
||||
}
|
||||
302
lib/services/geolocation.ts
Normal file
302
lib/services/geolocation.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Geolocation Service
|
||||
* Provides location-based features for connecting plant owners
|
||||
*/
|
||||
|
||||
import { PlantData, PlantLocation } from '../blockchain/types';
|
||||
|
||||
export interface PlantCluster {
|
||||
centerLat: number;
|
||||
centerLon: number;
|
||||
plantCount: number;
|
||||
plants: PlantData[];
|
||||
radius: number; // in km
|
||||
dominantSpecies: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionSuggestion {
|
||||
plant1: PlantData;
|
||||
plant2: PlantData;
|
||||
distance: number;
|
||||
matchReason: string; // e.g., "same species", "same lineage", "nearby location"
|
||||
compatibilityScore: number; // 0-100
|
||||
}
|
||||
|
||||
export class GeolocationService {
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
*/
|
||||
calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRadians(lat2 - lat1);
|
||||
const dLon = this.toRadians(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRadians(lat1)) *
|
||||
Math.cos(this.toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
private toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plant clusters in a region
|
||||
* Groups nearby plants together to show hotspots of activity
|
||||
*/
|
||||
findPlantClusters(
|
||||
plants: PlantData[],
|
||||
clusterRadius: number = 10 // km
|
||||
): PlantCluster[] {
|
||||
const clusters: PlantCluster[] = [];
|
||||
const processed = new Set<string>();
|
||||
|
||||
for (const plant of plants) {
|
||||
if (processed.has(plant.id)) continue;
|
||||
|
||||
// Find all plants within cluster radius
|
||||
const clusterPlants: PlantData[] = [plant];
|
||||
processed.add(plant.id);
|
||||
|
||||
for (const otherPlant of plants) {
|
||||
if (processed.has(otherPlant.id)) continue;
|
||||
|
||||
const distance = this.calculateDistance(
|
||||
plant.location.latitude,
|
||||
plant.location.longitude,
|
||||
otherPlant.location.latitude,
|
||||
otherPlant.location.longitude
|
||||
);
|
||||
|
||||
if (distance <= clusterRadius) {
|
||||
clusterPlants.push(otherPlant);
|
||||
processed.add(otherPlant.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cluster center (average of all positions)
|
||||
const centerLat =
|
||||
clusterPlants.reduce((sum, p) => sum + p.location.latitude, 0) /
|
||||
clusterPlants.length;
|
||||
const centerLon =
|
||||
clusterPlants.reduce((sum, p) => sum + p.location.longitude, 0) /
|
||||
clusterPlants.length;
|
||||
|
||||
// Find dominant species
|
||||
const speciesCount: { [key: string]: number } = {};
|
||||
for (const p of clusterPlants) {
|
||||
if (p.scientificName) {
|
||||
speciesCount[p.scientificName] =
|
||||
(speciesCount[p.scientificName] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const dominantSpecies = Object.entries(speciesCount)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([species]) => species);
|
||||
|
||||
clusters.push({
|
||||
centerLat,
|
||||
centerLon,
|
||||
plantCount: clusterPlants.length,
|
||||
plants: clusterPlants,
|
||||
radius: clusterRadius,
|
||||
dominantSpecies,
|
||||
});
|
||||
}
|
||||
|
||||
return clusters.sort((a, b) => b.plantCount - a.plantCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest connections between plant owners
|
||||
* Finds compatible plants for sharing/trading
|
||||
*/
|
||||
suggestConnections(
|
||||
userPlant: PlantData,
|
||||
allPlants: PlantData[],
|
||||
maxDistance: number = 50 // km
|
||||
): ConnectionSuggestion[] {
|
||||
const suggestions: ConnectionSuggestion[] = [];
|
||||
|
||||
for (const otherPlant of allPlants) {
|
||||
// Skip if same owner
|
||||
if (otherPlant.owner.id === userPlant.owner.id) continue;
|
||||
|
||||
// Skip if same plant
|
||||
if (otherPlant.id === userPlant.id) continue;
|
||||
|
||||
const distance = this.calculateDistance(
|
||||
userPlant.location.latitude,
|
||||
userPlant.location.longitude,
|
||||
otherPlant.location.latitude,
|
||||
otherPlant.location.longitude
|
||||
);
|
||||
|
||||
// Skip if too far
|
||||
if (distance > maxDistance) continue;
|
||||
|
||||
let matchReason = '';
|
||||
let compatibilityScore = 0;
|
||||
|
||||
// Check for same species
|
||||
if (
|
||||
userPlant.scientificName &&
|
||||
userPlant.scientificName === otherPlant.scientificName
|
||||
) {
|
||||
matchReason = 'Same species';
|
||||
compatibilityScore += 40;
|
||||
}
|
||||
|
||||
// Check for same lineage
|
||||
if (
|
||||
userPlant.parentPlantId === otherPlant.parentPlantId &&
|
||||
userPlant.parentPlantId
|
||||
) {
|
||||
matchReason =
|
||||
matchReason + (matchReason ? ', ' : '') + 'Same parent plant';
|
||||
compatibilityScore += 30;
|
||||
}
|
||||
|
||||
// Check for same genus
|
||||
if (userPlant.genus && userPlant.genus === otherPlant.genus) {
|
||||
if (!matchReason) matchReason = 'Same genus';
|
||||
compatibilityScore += 20;
|
||||
}
|
||||
|
||||
// Proximity bonus
|
||||
const proximityScore = Math.max(0, 20 - distance / 2.5);
|
||||
compatibilityScore += proximityScore;
|
||||
|
||||
// Only suggest if there's some compatibility
|
||||
if (compatibilityScore > 20) {
|
||||
if (!matchReason) matchReason = 'Nearby location';
|
||||
|
||||
suggestions.push({
|
||||
plant1: userPlant,
|
||||
plant2: otherPlant,
|
||||
distance,
|
||||
matchReason,
|
||||
compatibilityScore: Math.min(100, compatibilityScore),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => b.compatibilityScore - a.compatibilityScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get address from coordinates using reverse geocoding
|
||||
* Note: In production, you'd use a service like Google Maps or OpenStreetMap
|
||||
*/
|
||||
async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<Partial<PlantLocation>> {
|
||||
try {
|
||||
// Using OpenStreetMap Nominatim (free, but rate-limited)
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'LocalGreenChain/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Reverse geocoding error:', response.statusText);
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
address: data.display_name,
|
||||
city: data.address?.city || data.address?.town || data.address?.village,
|
||||
country: data.address?.country,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in reverse geocoding:', error);
|
||||
return { latitude, longitude };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coordinates from address using forward geocoding
|
||||
*/
|
||||
async geocode(
|
||||
address: string
|
||||
): Promise<{ latitude: number; longitude: number } | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'LocalGreenChain/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Geocoding error:', response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return {
|
||||
latitude: parseFloat(data[0].lat),
|
||||
longitude: parseFloat(data[0].lon),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in geocoding:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a location is within a given boundary
|
||||
*/
|
||||
isWithinBounds(
|
||||
location: { latitude: number; longitude: number },
|
||||
bounds: {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
}
|
||||
): boolean {
|
||||
return (
|
||||
location.latitude <= bounds.north &&
|
||||
location.latitude >= bounds.south &&
|
||||
location.longitude <= bounds.east &&
|
||||
location.longitude >= bounds.west
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let geolocationService: GeolocationService | null = null;
|
||||
|
||||
export function getGeolocationService(): GeolocationService {
|
||||
if (!geolocationService) {
|
||||
geolocationService = new GeolocationService();
|
||||
}
|
||||
return geolocationService;
|
||||
}
|
||||
225
lib/services/plantsnet.ts
Normal file
225
lib/services/plantsnet.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* Plants.net API Integration Service
|
||||
* Provides connectivity to the plants.net API for plant identification,
|
||||
* data enrichment, and community features
|
||||
*/
|
||||
|
||||
export interface PlantsNetSearchResult {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
genus?: string;
|
||||
family?: string;
|
||||
imageUrl?: string;
|
||||
description?: string;
|
||||
careInstructions?: string;
|
||||
}
|
||||
|
||||
export interface PlantsNetCommunity {
|
||||
nearbyGrowers: {
|
||||
userId: string;
|
||||
username: string;
|
||||
location: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
distance?: number;
|
||||
};
|
||||
plantsOwned: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export class PlantsNetService {
|
||||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this.apiKey = apiKey || process.env.PLANTS_NET_API_KEY || '';
|
||||
this.baseUrl = 'https://api.plants.net/v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for plant by common or scientific name
|
||||
*/
|
||||
async searchPlant(query: string): Promise<PlantsNetSearchResult[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/search?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.results || [];
|
||||
} catch (error) {
|
||||
console.error('Error searching plants.net:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed plant information by ID
|
||||
*/
|
||||
async getPlantDetails(plantId: string): Promise<PlantsNetSearchResult | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/plants/${plantId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching plant details:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nearby growers who have similar plants
|
||||
*/
|
||||
async findNearbyGrowers(
|
||||
plantSpecies: string,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusKm: number = 50
|
||||
): Promise<PlantsNetCommunity> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/community/nearby?species=${encodeURIComponent(plantSpecies)}&lat=${latitude}&lon=${longitude}&radius=${radiusKm}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return { nearbyGrowers: [] };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error finding nearby growers:', error);
|
||||
return { nearbyGrowers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify plant from image (if API supports it)
|
||||
*/
|
||||
async identifyPlantFromImage(imageUrl: string): Promise<PlantsNetSearchResult[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/identify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ imageUrl }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.suggestions || [];
|
||||
} catch (error) {
|
||||
console.error('Error identifying plant:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get care instructions for a plant
|
||||
*/
|
||||
async getCareInstructions(scientificName: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/care/${encodeURIComponent(scientificName)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.careInstructions || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching care instructions:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a plant to the plants.net network
|
||||
* This allows integration with their global plant tracking
|
||||
*/
|
||||
async reportPlantToNetwork(plantData: {
|
||||
commonName: string;
|
||||
scientificName?: string;
|
||||
location: { latitude: number; longitude: number };
|
||||
ownerId: string;
|
||||
propagationType?: string;
|
||||
}): Promise<{ success: boolean; plantsNetId?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/reports`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(plantData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Plants.net API error:', response.statusText);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
plantsNetId: data.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reporting plant to network:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let plantsNetService: PlantsNetService | null = null;
|
||||
|
||||
export function getPlantsNetService(): PlantsNetService {
|
||||
if (!plantsNetService) {
|
||||
plantsNetService = new PlantsNetService();
|
||||
}
|
||||
return plantsNetService;
|
||||
}
|
||||
172
lib/services/tor.ts
Normal file
172
lib/services/tor.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Tor Integration Service
|
||||
* Provides Tor network connectivity for anonymous plant sharing
|
||||
*/
|
||||
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
|
||||
export interface TorConfig {
|
||||
enabled: boolean;
|
||||
socksHost: string;
|
||||
socksPort: number;
|
||||
controlPort: number;
|
||||
hiddenServiceDir?: string;
|
||||
}
|
||||
|
||||
export class TorService {
|
||||
private config: TorConfig;
|
||||
private proxyAgent: any;
|
||||
|
||||
constructor(config?: Partial<TorConfig>) {
|
||||
this.config = {
|
||||
enabled: process.env.TOR_ENABLED === 'true',
|
||||
socksHost: process.env.TOR_SOCKS_HOST || '127.0.0.1',
|
||||
socksPort: parseInt(process.env.TOR_SOCKS_PORT || '9050'),
|
||||
controlPort: parseInt(process.env.TOR_CONTROL_PORT || '9051'),
|
||||
hiddenServiceDir: process.env.TOR_HIDDEN_SERVICE_DIR,
|
||||
...config,
|
||||
};
|
||||
|
||||
if (this.config.enabled) {
|
||||
this.initializeProxy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize SOCKS proxy for Tor
|
||||
*/
|
||||
private initializeProxy(): void {
|
||||
const proxyUrl = `socks5://${this.config.socksHost}:${this.config.socksPort}`;
|
||||
this.proxyAgent = new SocksProxyAgent(proxyUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Tor is available and running
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (!this.config.enabled) return false;
|
||||
|
||||
try {
|
||||
// Try to fetch check.torproject.org through Tor
|
||||
const response = await fetch('https://check.torproject.org/api/ip', {
|
||||
// @ts-ignore
|
||||
agent: this.proxyAgent,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.IsTor === true;
|
||||
} catch (error) {
|
||||
console.error('Tor availability check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data through Tor network
|
||||
*/
|
||||
async fetchThroughTor(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
if (!this.config.enabled) {
|
||||
throw new Error('Tor is not enabled');
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
// @ts-ignore
|
||||
agent: this.proxyAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current Tor circuit info
|
||||
*/
|
||||
async getCircuitInfo(): Promise<{ country?: string; ip?: string }> {
|
||||
try {
|
||||
const response = await this.fetchThroughTor('https://check.torproject.org/api/ip');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request new Tor identity (new circuit)
|
||||
*/
|
||||
async requestNewIdentity(): Promise<boolean> {
|
||||
// This would require Tor control port access
|
||||
// For now, just return true if Tor is enabled
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get onion service hostname if available
|
||||
*/
|
||||
getOnionAddress(): string | null {
|
||||
if (!this.config.hiddenServiceDir) return null;
|
||||
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const hostnamePath = path.join(this.config.hiddenServiceDir, 'hostname');
|
||||
|
||||
if (fs.existsSync(hostnamePath)) {
|
||||
return fs.readFileSync(hostnamePath, 'utf-8').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading onion address:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for onion address
|
||||
*/
|
||||
async generateOnionQRCode(): Promise<string | null> {
|
||||
const onionAddress = this.getOnionAddress();
|
||||
if (!onionAddress) return null;
|
||||
|
||||
// In production, you'd use a QR code library
|
||||
// For now, return the address
|
||||
return `http://${onionAddress}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request came through Tor
|
||||
*/
|
||||
isRequestFromTor(headers: any): boolean {
|
||||
// Check various indicators that request came through Tor
|
||||
return (
|
||||
headers['x-tor-connection'] === 'true' ||
|
||||
headers['x-forwarded-proto'] === 'tor' ||
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let torService: TorService | null = null;
|
||||
|
||||
export function getTorService(): TorService {
|
||||
if (!torService) {
|
||||
torService = new TorService();
|
||||
}
|
||||
return torService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to detect and mark Tor connections
|
||||
*/
|
||||
export function torDetectionMiddleware(req: any, res: any, next: any) {
|
||||
const torService = getTorService();
|
||||
|
||||
// Mark request as coming from Tor if detected
|
||||
req.isTorConnection = torService.isRequestFromTor(req.headers);
|
||||
|
||||
// Add Tor status to response headers
|
||||
if (req.isTorConnection) {
|
||||
res.setHeader('X-Tor-Enabled', 'true');
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
14
package.json
14
package.json
|
|
@ -1,17 +1,18 @@
|
|||
{
|
||||
"name": "example-marketing",
|
||||
"version": "1.5.0",
|
||||
"name": "localgreenchain",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"preview": "next build && next start -p 3001",
|
||||
"start": "next start -p 3001",
|
||||
"preview": "bun run build && bun run start",
|
||||
"lint": "next lint",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"test:e2e": "start-server-and-test 'yarn preview' http://localhost:3001 cy:open",
|
||||
"test:e2e:ci": "start-server-and-test 'yarn preview' http://localhost:3001 cy:run"
|
||||
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
||||
"test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
|
|
@ -25,7 +26,8 @@
|
|||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.8.6"
|
||||
"react-hook-form": "^7.8.6",
|
||||
"socks-proxy-agent": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
|
|
|
|||
95
pages/api/environment/analysis.ts
Normal file
95
pages/api/environment/analysis.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* API Route: Analyze growth correlations across all plants
|
||||
* GET /api/environment/analysis?species=tomato (optional species filter)
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { analyzeGrowthCorrelation } from '../../../lib/environment/analysis';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { species } = req.query;
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
// Get all plants
|
||||
let allPlants = Array.from(
|
||||
new Set(blockchain.chain.map(block => block.plant.id))
|
||||
)
|
||||
.map(id => blockchain.getPlant(id)!)
|
||||
.filter(Boolean);
|
||||
|
||||
// Filter by species if specified
|
||||
if (species && typeof species === 'string') {
|
||||
allPlants = allPlants.filter(
|
||||
p =>
|
||||
p.scientificName?.toLowerCase().includes(species.toLowerCase()) ||
|
||||
p.commonName?.toLowerCase().includes(species.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Only include plants with environmental data
|
||||
const plantsWithEnv = allPlants.filter(p => p.environment);
|
||||
|
||||
if (plantsWithEnv.length === 0) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'No plants with environmental data found',
|
||||
plantsAnalyzed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const analysis = analyzeGrowthCorrelation(plantsWithEnv);
|
||||
|
||||
// Calculate some additional statistics
|
||||
const successRate =
|
||||
(plantsWithEnv.filter(
|
||||
p => p.status === 'mature' || p.status === 'flowering' || p.status === 'fruiting'
|
||||
).length /
|
||||
plantsWithEnv.length) *
|
||||
100;
|
||||
|
||||
const locationTypes = plantsWithEnv.reduce((acc, p) => {
|
||||
const type = p.environment!.location.type;
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const soilTypes = plantsWithEnv.reduce((acc, p) => {
|
||||
const type = p.environment!.soil.type;
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plantsAnalyzed: plantsWithEnv.length,
|
||||
successRate: Math.round(successRate),
|
||||
insights: analysis.insights,
|
||||
statistics: {
|
||||
locationTypes,
|
||||
soilTypes,
|
||||
avgTemperature:
|
||||
plantsWithEnv.reduce((sum, p) => sum + (p.environment!.climate.temperatureDay || 0), 0) /
|
||||
plantsWithEnv.length,
|
||||
avgHumidity:
|
||||
plantsWithEnv.reduce((sum, p) => sum + (p.environment!.climate.humidityAverage || 0), 0) /
|
||||
plantsWithEnv.length,
|
||||
avgSoilPH:
|
||||
plantsWithEnv.reduce((sum, p) => sum + (p.environment!.soil.pH || 0), 0) /
|
||||
plantsWithEnv.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error analyzing growth correlation:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
80
pages/api/environment/compare.ts
Normal file
80
pages/api/environment/compare.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* API Route: Compare growing environments of two plants
|
||||
* GET /api/environment/compare?plant1=xyz&plant2=abc
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { compareEnvironments } from '../../../lib/environment/analysis';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plant1, plant2 } = req.query;
|
||||
|
||||
if (!plant1 || !plant2 || typeof plant1 !== 'string' || typeof plant2 !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing plant1 and plant2 parameters' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const plantData1 = blockchain.getPlant(plant1);
|
||||
const plantData2 = blockchain.getPlant(plant2);
|
||||
|
||||
if (!plantData1 || !plantData2) {
|
||||
return res.status(404).json({ error: 'One or both plants not found' });
|
||||
}
|
||||
|
||||
if (!plantData1.environment || !plantData2.environment) {
|
||||
return res.status(400).json({
|
||||
error: 'Both plants must have environmental data',
|
||||
plant1HasData: !!plantData1.environment,
|
||||
plant2HasData: !!plantData2.environment,
|
||||
});
|
||||
}
|
||||
|
||||
const comparison = compareEnvironments(plantData1.environment, plantData2.environment);
|
||||
comparison.plant1 = plant1;
|
||||
comparison.plant2 = plant2;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plants: {
|
||||
plant1: {
|
||||
id: plantData1.id,
|
||||
commonName: plantData1.commonName,
|
||||
scientificName: plantData1.scientificName,
|
||||
owner: plantData1.owner.name,
|
||||
},
|
||||
plant2: {
|
||||
id: plantData2.id,
|
||||
commonName: plantData2.commonName,
|
||||
scientificName: plantData2.scientificName,
|
||||
owner: plantData2.owner.name,
|
||||
},
|
||||
},
|
||||
comparison: {
|
||||
similarityScore: comparison.score,
|
||||
similarities: comparison.similarities,
|
||||
differences: comparison.differences,
|
||||
},
|
||||
interpretation: getScoreInterpretation(comparison.score),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error comparing environments:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
function getScoreInterpretation(score: number): string {
|
||||
if (score >= 90) return 'Nearly identical growing conditions';
|
||||
if (score >= 75) return 'Very similar environments - likely to have similar results';
|
||||
if (score >= 60) return 'Moderately similar conditions';
|
||||
if (score >= 40) return 'Some similarities but notable differences';
|
||||
return 'Very different growing environments';
|
||||
}
|
||||
61
pages/api/environment/recommendations.ts
Normal file
61
pages/api/environment/recommendations.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* API Route: Get environmental recommendations for a plant
|
||||
* GET /api/environment/recommendations?plantId=xyz
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import {
|
||||
generateRecommendations,
|
||||
calculateEnvironmentalHealth,
|
||||
} from '../../../lib/environment/analysis';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plantId } = req.query;
|
||||
|
||||
if (!plantId || typeof plantId !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing plantId parameter' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.getPlant(plantId);
|
||||
|
||||
if (!plant) {
|
||||
return res.status(404).json({ error: 'Plant not found' });
|
||||
}
|
||||
|
||||
const recommendations = generateRecommendations(plant);
|
||||
|
||||
const healthScore = plant.environment
|
||||
? calculateEnvironmentalHealth(plant.environment)
|
||||
: null;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plant: {
|
||||
id: plant.id,
|
||||
commonName: plant.commonName,
|
||||
scientificName: plant.scientificName,
|
||||
},
|
||||
environmentalHealth: healthScore,
|
||||
recommendations,
|
||||
summary: {
|
||||
criticalIssues: recommendations.filter(r => r.priority === 'critical').length,
|
||||
highPriority: recommendations.filter(r => r.priority === 'high').length,
|
||||
mediumPriority: recommendations.filter(r => r.priority === 'medium').length,
|
||||
lowPriority: recommendations.filter(r => r.priority === 'low').length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error generating recommendations:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
74
pages/api/environment/similar.ts
Normal file
74
pages/api/environment/similar.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* API Route: Find plants with similar growing environments
|
||||
* GET /api/environment/similar?plantId=xyz&minScore=70
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { findSimilarEnvironments } from '../../../lib/environment/analysis';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plantId, minScore } = req.query;
|
||||
|
||||
if (!plantId || typeof plantId !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing plantId parameter' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const targetPlant = blockchain.getPlant(plantId);
|
||||
|
||||
if (!targetPlant) {
|
||||
return res.status(404).json({ error: 'Plant not found' });
|
||||
}
|
||||
|
||||
if (!targetPlant.environment) {
|
||||
return res.status(400).json({
|
||||
error: 'Plant has no environmental data',
|
||||
message: 'Add environmental information to find similar plants',
|
||||
});
|
||||
}
|
||||
|
||||
// Get all plants
|
||||
const allPlants = Array.from(
|
||||
new Set(blockchain.chain.map(block => block.plant.id))
|
||||
)
|
||||
.map(id => blockchain.getPlant(id)!)
|
||||
.filter(Boolean);
|
||||
|
||||
const minScoreValue = minScore ? parseInt(minScore as string) : 70;
|
||||
const similarPlants = findSimilarEnvironments(targetPlant, allPlants, minScoreValue);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
targetPlant: {
|
||||
id: targetPlant.id,
|
||||
commonName: targetPlant.commonName,
|
||||
scientificName: targetPlant.scientificName,
|
||||
},
|
||||
similarCount: similarPlants.length,
|
||||
similar: similarPlants.map(({ plant, comparison }) => ({
|
||||
plant: {
|
||||
id: plant.id,
|
||||
commonName: plant.commonName,
|
||||
scientificName: plant.scientificName,
|
||||
owner: plant.owner.name,
|
||||
location: plant.location.city || plant.location.country,
|
||||
},
|
||||
similarityScore: comparison.score,
|
||||
similarities: comparison.similarities,
|
||||
differences: comparison.differences,
|
||||
})),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error finding similar environments:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
59
pages/api/plants/[id].ts
Normal file
59
pages/api/plants/[id].ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* API Route: Get plant details by ID
|
||||
* GET /api/plants/[id]
|
||||
* PUT /api/plants/[id] - Update plant status
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain, saveBlockchain } from '../../../lib/blockchain/manager';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const { id } = req.query;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid plant ID' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const plant = blockchain.getPlant(id);
|
||||
|
||||
if (!plant) {
|
||||
return res.status(404).json({ error: 'Plant not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plant,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching plant:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
const block = blockchain.updatePlantStatus(id, updates);
|
||||
|
||||
// Save blockchain
|
||||
saveBlockchain();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plant: block.plant,
|
||||
message: 'Plant updated successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error updating plant:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
70
pages/api/plants/clone.ts
Normal file
70
pages/api/plants/clone.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* API Route: Clone a plant (create offspring)
|
||||
* POST /api/plants/clone
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain, saveBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { PlantData } from '../../../lib/blockchain/types';
|
||||
|
||||
interface CloneRequest {
|
||||
parentPlantId: string;
|
||||
propagationType: 'seed' | 'clone' | 'cutting' | 'division' | 'grafting';
|
||||
newPlant: Partial<PlantData>;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { parentPlantId, propagationType, newPlant }: CloneRequest = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!parentPlantId || !propagationType || !newPlant) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: parentPlantId, propagationType, newPlant',
|
||||
});
|
||||
}
|
||||
|
||||
if (!newPlant.location || !newPlant.owner) {
|
||||
return res.status(400).json({
|
||||
error: 'New plant must have location and owner',
|
||||
});
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
// Clone the plant
|
||||
const block = blockchain.clonePlant(
|
||||
parentPlantId,
|
||||
newPlant,
|
||||
propagationType
|
||||
);
|
||||
|
||||
// Save blockchain
|
||||
saveBlockchain();
|
||||
|
||||
// Get parent for context
|
||||
const parent = blockchain.getPlant(parentPlantId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
plant: block.plant,
|
||||
parent: parent,
|
||||
block: {
|
||||
index: block.index,
|
||||
hash: block.hash,
|
||||
timestamp: block.timestamp,
|
||||
},
|
||||
message: `Successfully created ${propagationType} from parent plant`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error cloning plant:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
61
pages/api/plants/connections.ts
Normal file
61
pages/api/plants/connections.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* API Route: Get connection suggestions for a plant
|
||||
* GET /api/plants/connections?plantId=xyz&maxDistance=50
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { getGeolocationService } from '../../../lib/services/geolocation';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plantId, maxDistance } = req.query;
|
||||
|
||||
if (!plantId || typeof plantId !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing plantId parameter' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.getPlant(plantId);
|
||||
|
||||
if (!plant) {
|
||||
return res.status(404).json({ error: 'Plant not found' });
|
||||
}
|
||||
|
||||
const maxDistanceKm = maxDistance ? parseFloat(maxDistance as string) : 50;
|
||||
|
||||
// Get all plants
|
||||
const allPlants = Array.from(
|
||||
new Set(blockchain.chain.map(block => block.plant.id))
|
||||
).map(id => blockchain.getPlant(id)!).filter(Boolean);
|
||||
|
||||
// Get connection suggestions
|
||||
const geoService = getGeolocationService();
|
||||
const suggestions = geoService.suggestConnections(
|
||||
plant,
|
||||
allPlants,
|
||||
maxDistanceKm
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plant: {
|
||||
id: plant.id,
|
||||
commonName: plant.commonName,
|
||||
owner: plant.owner.name,
|
||||
},
|
||||
suggestionCount: suggestions.length,
|
||||
suggestions: suggestions.slice(0, 20), // Limit to top 20
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting connection suggestions:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
45
pages/api/plants/lineage/[id].ts
Normal file
45
pages/api/plants/lineage/[id].ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* API Route: Get plant lineage (ancestors and descendants)
|
||||
* GET /api/plants/lineage/[id]
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../../lib/blockchain/manager';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = req.query;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid plant ID' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const lineage = blockchain.getPlantLineage(id);
|
||||
|
||||
if (!lineage) {
|
||||
return res.status(404).json({ error: 'Plant not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
lineage,
|
||||
stats: {
|
||||
ancestorCount: lineage.ancestors.length,
|
||||
descendantCount: lineage.descendants.length,
|
||||
siblingCount: lineage.siblings.length,
|
||||
generation: lineage.generation,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching plant lineage:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
68
pages/api/plants/nearby.ts
Normal file
68
pages/api/plants/nearby.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* API Route: Find nearby plants
|
||||
* GET /api/plants/nearby?lat=123&lon=456&radius=50
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { getGeolocationService } from '../../../lib/services/geolocation';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { lat, lon, radius, species } = req.query;
|
||||
|
||||
if (!lat || !lon) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required parameters: lat, lon',
|
||||
});
|
||||
}
|
||||
|
||||
const latitude = parseFloat(lat as string);
|
||||
const longitude = parseFloat(lon as string);
|
||||
const radiusKm = radius ? parseFloat(radius as string) : 50;
|
||||
|
||||
if (isNaN(latitude) || isNaN(longitude) || isNaN(radiusKm)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameters: lat, lon, and radius must be numbers',
|
||||
});
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
let nearbyPlants = blockchain.findNearbyPlants(latitude, longitude, radiusKm);
|
||||
|
||||
// Filter by species if provided
|
||||
if (species && typeof species === 'string') {
|
||||
nearbyPlants = nearbyPlants.filter(
|
||||
np => np.plant.scientificName === species || np.plant.commonName === species
|
||||
);
|
||||
}
|
||||
|
||||
// Get plant clusters
|
||||
const geoService = getGeolocationService();
|
||||
const allNearbyPlantData = nearbyPlants.map(np => np.plant);
|
||||
const clusters = geoService.findPlantClusters(allNearbyPlantData, 10);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: nearbyPlants.length,
|
||||
plants: nearbyPlants,
|
||||
clusters,
|
||||
searchParams: {
|
||||
latitude,
|
||||
longitude,
|
||||
radiusKm,
|
||||
species: species || null,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error finding nearby plants:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
38
pages/api/plants/network.ts
Normal file
38
pages/api/plants/network.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* API Route: Get network statistics
|
||||
* GET /api/plants/network
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const blockchain = getBlockchain();
|
||||
const networkStats = blockchain.getNetworkStats();
|
||||
|
||||
// Calculate additional metrics
|
||||
const chainLength = blockchain.chain.length;
|
||||
const isValid = blockchain.isChainValid();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
network: networkStats,
|
||||
blockchain: {
|
||||
totalBlocks: chainLength,
|
||||
isValid,
|
||||
difficulty: blockchain.difficulty,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching network stats:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
156
pages/api/plants/register-anonymous.ts
Normal file
156
pages/api/plants/register-anonymous.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* API Route: Register an anonymous plant
|
||||
* POST /api/plants/register-anonymous
|
||||
*
|
||||
* This endpoint allows for privacy-preserving plant registration
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain, saveBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { PlantData } from '../../../lib/blockchain/types';
|
||||
import {
|
||||
generateAnonymousId,
|
||||
generateWalletAddress,
|
||||
obfuscateLocation,
|
||||
generateAnonymousPlantName,
|
||||
createAnonymousContact,
|
||||
PrivacySettings,
|
||||
} from '../../../lib/privacy/anonymity';
|
||||
import { getTorService } from '../../../lib/services/tor';
|
||||
|
||||
interface AnonymousPlantRequest {
|
||||
commonName: string;
|
||||
scientificName?: string;
|
||||
species?: string;
|
||||
genus?: string;
|
||||
family?: string;
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
privacySettings: PrivacySettings;
|
||||
pseudonym?: string;
|
||||
encryptionKey?: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const requestData: AnonymousPlantRequest = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!requestData.commonName || !requestData.location || !requestData.privacySettings) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: commonName, location, privacySettings',
|
||||
});
|
||||
}
|
||||
|
||||
const { location, privacySettings } = requestData;
|
||||
|
||||
// Check if request came through Tor
|
||||
const torService = getTorService();
|
||||
const isTorConnection = torService.isRequestFromTor(req.headers);
|
||||
|
||||
// Generate anonymous identifiers
|
||||
const anonymousUserId = generateAnonymousId();
|
||||
const walletAddress = generateWalletAddress();
|
||||
const plantId = `plant-${generateAnonymousId()}`;
|
||||
|
||||
// Obfuscate location based on privacy settings
|
||||
const fuzzyLocation = obfuscateLocation(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
privacySettings.locationPrivacy
|
||||
);
|
||||
|
||||
// Determine display name based on privacy settings
|
||||
let displayName: string;
|
||||
if (privacySettings.identityPrivacy === 'anonymous') {
|
||||
displayName = 'Anonymous Grower';
|
||||
} else if (privacySettings.identityPrivacy === 'pseudonym' && requestData.pseudonym) {
|
||||
displayName = requestData.pseudonym;
|
||||
} else {
|
||||
displayName = `Grower-${anonymousUserId.substring(0, 8)}`;
|
||||
}
|
||||
|
||||
// Create anonymous contact
|
||||
const anonymousEmail = createAnonymousContact(
|
||||
anonymousUserId,
|
||||
requestData.encryptionKey || 'default-key'
|
||||
);
|
||||
|
||||
// Build plant data with privacy protections
|
||||
const plantData: PlantData = {
|
||||
id: plantId,
|
||||
commonName: privacySettings.sharePlantDetails
|
||||
? requestData.commonName
|
||||
: generateAnonymousPlantName(requestData.commonName, 0),
|
||||
scientificName: privacySettings.sharePlantDetails ? requestData.scientificName : undefined,
|
||||
species: privacySettings.sharePlantDetails ? requestData.species : undefined,
|
||||
genus: privacySettings.sharePlantDetails ? requestData.genus : undefined,
|
||||
family: privacySettings.sharePlantDetails ? requestData.family : undefined,
|
||||
propagationType: 'original',
|
||||
generation: 0,
|
||||
plantedDate: new Date().toISOString(),
|
||||
status: 'growing',
|
||||
location: {
|
||||
latitude: fuzzyLocation.latitude,
|
||||
longitude: fuzzyLocation.longitude,
|
||||
address: fuzzyLocation.displayName,
|
||||
city: privacySettings.locationPrivacy !== 'hidden' ? 'Privacy Protected' : undefined,
|
||||
country: privacySettings.locationPrivacy === 'country' ? 'Anonymous Region' : undefined,
|
||||
},
|
||||
owner: {
|
||||
id: anonymousUserId,
|
||||
name: displayName,
|
||||
email: anonymousEmail,
|
||||
walletAddress: walletAddress,
|
||||
},
|
||||
childPlants: [],
|
||||
registeredAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
notes: privacySettings.anonymousMode
|
||||
? 'Registered anonymously via privacy mode'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
// Register the plant
|
||||
const block = blockchain.registerPlant(plantData);
|
||||
|
||||
// Save blockchain
|
||||
saveBlockchain();
|
||||
|
||||
// Prepare response with privacy info
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
plant: block.plant,
|
||||
privacy: {
|
||||
anonymousId: anonymousUserId,
|
||||
walletAddress: walletAddress,
|
||||
locationAccuracy: fuzzyLocation.accuracy,
|
||||
torConnection: isTorConnection,
|
||||
privacyLevel: privacySettings.locationPrivacy,
|
||||
},
|
||||
block: {
|
||||
index: block.index,
|
||||
hash: block.hash,
|
||||
timestamp: block.timestamp,
|
||||
},
|
||||
message: 'Plant registered anonymously',
|
||||
warning: !isTorConnection
|
||||
? 'For maximum privacy, consider accessing through Tor network'
|
||||
: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error registering anonymous plant:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
69
pages/api/plants/register.ts
Normal file
69
pages/api/plants/register.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* API Route: Register a new plant
|
||||
* POST /api/plants/register
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain, saveBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { PlantData } from '../../../lib/blockchain/types';
|
||||
import { getPlantsNetService } from '../../../lib/services/plantsnet';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const plantData: PlantData = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!plantData.id || !plantData.commonName || !plantData.owner || !plantData.location) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: id, commonName, owner, location',
|
||||
});
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
// Register the plant
|
||||
const block = blockchain.registerPlant(plantData);
|
||||
|
||||
// Optionally report to plants.net
|
||||
if (process.env.PLANTS_NET_API_KEY) {
|
||||
const plantsNet = getPlantsNetService();
|
||||
const result = await plantsNet.reportPlantToNetwork({
|
||||
commonName: plantData.commonName,
|
||||
scientificName: plantData.scientificName,
|
||||
location: plantData.location,
|
||||
ownerId: plantData.owner.id,
|
||||
propagationType: plantData.propagationType,
|
||||
});
|
||||
|
||||
if (result.success && result.plantsNetId) {
|
||||
// Update plant with plants.net ID
|
||||
blockchain.updatePlantStatus(plantData.id, {
|
||||
plantsNetId: result.plantsNetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save blockchain
|
||||
saveBlockchain();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
plant: block.plant,
|
||||
block: {
|
||||
index: block.index,
|
||||
hash: block.hash,
|
||||
timestamp: block.timestamp,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error registering plant:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
87
pages/api/plants/search.ts
Normal file
87
pages/api/plants/search.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* API Route: Search for plants
|
||||
* GET /api/plants/search?q=tomato&type=species
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getBlockchain } from '../../../lib/blockchain/manager';
|
||||
import { getPlantsNetService } from '../../../lib/services/plantsnet';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { q, type } = req.query;
|
||||
|
||||
if (!q || typeof q !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing search query parameter: q' });
|
||||
}
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const searchTerm = q.toLowerCase();
|
||||
|
||||
// Search in blockchain
|
||||
const allPlants = Array.from(
|
||||
new Set(blockchain.chain.map(block => block.plant.id))
|
||||
).map(id => blockchain.getPlant(id)!);
|
||||
|
||||
let results = allPlants.filter(plant => {
|
||||
if (!plant) return false;
|
||||
|
||||
const searchIn = [
|
||||
plant.commonName?.toLowerCase(),
|
||||
plant.scientificName?.toLowerCase(),
|
||||
plant.genus?.toLowerCase(),
|
||||
plant.family?.toLowerCase(),
|
||||
plant.owner.name?.toLowerCase(),
|
||||
].filter(Boolean);
|
||||
|
||||
return searchIn.some(field => field?.includes(searchTerm));
|
||||
});
|
||||
|
||||
// Filter by type if specified
|
||||
if (type) {
|
||||
switch (type) {
|
||||
case 'species':
|
||||
results = results.filter(p =>
|
||||
p.scientificName?.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
break;
|
||||
case 'owner':
|
||||
results = results.filter(p =>
|
||||
p.owner.name?.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
break;
|
||||
case 'location':
|
||||
results = results.filter(
|
||||
p =>
|
||||
p.location.city?.toLowerCase().includes(searchTerm) ||
|
||||
p.location.country?.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also search plants.net if API key is available
|
||||
let plantsNetResults = [];
|
||||
if (process.env.PLANTS_NET_API_KEY) {
|
||||
const plantsNet = getPlantsNetService();
|
||||
plantsNetResults = await plantsNet.searchPlant(q);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: results.length,
|
||||
results: results,
|
||||
plantsNetResults: plantsNetResults,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error searching plants:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
70
pages/api/privacy/tor-status.ts
Normal file
70
pages/api/privacy/tor-status.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* API Route: Check Tor status
|
||||
* GET /api/privacy/tor-status
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getTorService } from '../../../lib/services/tor';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const torService = getTorService();
|
||||
|
||||
// Check if Tor is enabled in configuration
|
||||
const isEnabled = process.env.TOR_ENABLED === 'true';
|
||||
|
||||
// Check if request came through Tor
|
||||
const isTorConnection = torService.isRequestFromTor(req.headers);
|
||||
|
||||
let isAvailable = false;
|
||||
let circuitInfo = null;
|
||||
let onionAddress = null;
|
||||
|
||||
if (isEnabled) {
|
||||
// Check if Tor daemon is available
|
||||
try {
|
||||
isAvailable = await torService.isAvailable();
|
||||
|
||||
if (isAvailable) {
|
||||
circuitInfo = await torService.getCircuitInfo();
|
||||
onionAddress = torService.getOnionAddress();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Tor availability:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
tor: {
|
||||
enabled: isEnabled,
|
||||
available: isAvailable,
|
||||
connectionThroughTor: isTorConnection,
|
||||
onionAddress: onionAddress,
|
||||
circuit: circuitInfo,
|
||||
},
|
||||
privacy: {
|
||||
recommendTor: !isTorConnection,
|
||||
privacyLevel: isTorConnection ? 'high' : 'standard',
|
||||
ip: isTorConnection ? 'Hidden via Tor' : 'Visible',
|
||||
},
|
||||
recommendations: isTorConnection
|
||||
? ['Your connection is private via Tor', 'Anonymous plant registration available']
|
||||
: [
|
||||
'For maximum privacy, access via Tor Browser',
|
||||
'Download Tor from https://www.torproject.org',
|
||||
`Or connect to our onion service: ${onionAddress || 'Not available'}`,
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error checking Tor status:', error);
|
||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
281
pages/index.tsx
Normal file
281
pages/index.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
|
||||
interface NetworkStats {
|
||||
totalPlants: number;
|
||||
totalOwners: number;
|
||||
species: { [key: string]: number };
|
||||
globalDistribution: { [key: string]: number };
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<NetworkStats | null>(null);
|
||||
const [blockchainInfo, setBlockchainInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNetworkStats();
|
||||
}, []);
|
||||
|
||||
const fetchNetworkStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/plants/network');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setStats(data.network);
|
||||
setBlockchainInfo(data.blockchain);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching network stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
<Head>
|
||||
<title>LocalGreenChain - Plant Cloning Blockchain</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track plant lineage and connect with growers worldwide"
|
||||
/>
|
||||
</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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-green-800">
|
||||
🌱 LocalGreenChain
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Plant Cloning Blockchain Network
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<Link href="/plants/explore">
|
||||
<a className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Explore Network
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Track Your Plant's Journey
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
A blockchain for plants that preserves lineage across clones and
|
||||
seeds. Connect with growers, share plants, and build a global green
|
||||
network.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Network Stats */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading network stats...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-12">
|
||||
<StatCard
|
||||
title="Total Plants"
|
||||
value={stats?.totalPlants || 0}
|
||||
icon="🌿"
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Growers"
|
||||
value={stats?.totalOwners || 0}
|
||||
icon="👥"
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Species"
|
||||
value={Object.keys(stats?.species || {}).length}
|
||||
icon="🌺"
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Blockchain Blocks"
|
||||
value={blockchainInfo?.totalBlocks || 0}
|
||||
icon="⛓️"
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<FeatureCard
|
||||
title="Track Lineage"
|
||||
description="Every clone, cutting, and seed is recorded on the blockchain, preserving your plant's family tree forever."
|
||||
icon="🌳"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Connect Locally"
|
||||
description="Find growers near you with similar plants. Share clones, trade seeds, and build your local plant community."
|
||||
icon="📍"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Global Network"
|
||||
description="Join a worldwide network of plant enthusiasts. Track how your plants spread across the globe."
|
||||
icon="🌍"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Species */}
|
||||
{stats && Object.keys(stats.species).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-12">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Popular Species
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(stats.species)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([species, count]) => (
|
||||
<div
|
||||
key={species}
|
||||
className="flex justify-between items-center p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<span className="font-medium text-gray-800">
|
||||
{species}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-semibold">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blockchain Status */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Blockchain Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Status</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{blockchainInfo?.isValid ? (
|
||||
<span className="text-green-600">✓ Valid</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗ Invalid</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Mining Difficulty</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{blockchainInfo?.difficulty || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Total Blocks</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{blockchainInfo?.totalBlocks || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="mt-12 bg-gradient-to-r from-green-600 to-emerald-600 rounded-lg shadow-xl p-8 text-center text-white">
|
||||
<h3 className="text-3xl font-bold mb-4">Ready to Get Started?</h3>
|
||||
<p className="text-lg mb-6 opacity-90">
|
||||
Register your first plant and join the global green blockchain
|
||||
network.
|
||||
</p>
|
||||
<Link href="/plants/register">
|
||||
<a className="inline-block px-8 py-3 bg-white text-green-600 font-semibold rounded-lg hover:bg-gray-100 transition">
|
||||
Register Your First Plant
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-20 bg-white border-t border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-600">
|
||||
© 2025 LocalGreenChain. Powered by blockchain technology. 🌱
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: string;
|
||||
color: string;
|
||||
}) {
|
||||
const colorClasses = {
|
||||
green: 'bg-green-100 text-green-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
orange: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`text-4xl ${colorClasses[color as keyof typeof colorClasses]
|
||||
} w-16 h-16 rounded-full flex items-center justify-center`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
||||
<div className="text-5xl mb-4">{icon}</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
491
pages/plants/[id].tsx
Normal file
491
pages/plants/[id].tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
import EnvironmentalDisplay from '../../components/EnvironmentalDisplay';
|
||||
import { GrowingEnvironment } from '../../lib/environment/types';
|
||||
|
||||
interface Plant {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName?: string;
|
||||
species?: string;
|
||||
genus?: string;
|
||||
family?: string;
|
||||
parentPlantId?: string;
|
||||
propagationType?: string;
|
||||
generation: number;
|
||||
plantedDate: string;
|
||||
status: string;
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
address?: string;
|
||||
};
|
||||
owner: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
childPlants: string[];
|
||||
environment?: GrowingEnvironment;
|
||||
notes?: string;
|
||||
registeredAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Lineage {
|
||||
plantId: string;
|
||||
ancestors: Plant[];
|
||||
descendants: Plant[];
|
||||
siblings: Plant[];
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export default function PlantDetail() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [plant, setPlant] = useState<Plant | null>(null);
|
||||
const [lineage, setLineage] = useState<Lineage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'lineage' | 'environment'>('details');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchPlantData();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchPlantData = async () => {
|
||||
try {
|
||||
const [plantResponse, lineageResponse] = await Promise.all([
|
||||
fetch(`/api/plants/${id}`),
|
||||
fetch(`/api/plants/lineage/${id}`),
|
||||
]);
|
||||
|
||||
const plantData = await plantResponse.json();
|
||||
const lineageData = await lineageResponse.json();
|
||||
|
||||
if (!plantResponse.ok) {
|
||||
throw new Error(plantData.error || 'Failed to fetch plant');
|
||||
}
|
||||
|
||||
setPlant(plantData.plant);
|
||||
|
||||
if (lineageResponse.ok) {
|
||||
setLineage(lineageData.lineage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading plant data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plant) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Error</h2>
|
||||
<p className="text-gray-700 mb-4">{error || 'Plant not found'}</p>
|
||||
<Link href="/">
|
||||
<a className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||||
Go Home
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
<Head>
|
||||
<title>
|
||||
{plant.commonName} - 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/explore">
|
||||
<a className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Explore
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/plants/clone?parentId=${plant.id}`}>
|
||||
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||||
Clone This 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">
|
||||
{/* Plant Header */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 mb-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
{plant.commonName}
|
||||
</h1>
|
||||
{plant.scientificName && (
|
||||
<p className="text-xl italic text-gray-600">
|
||||
{plant.scientificName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold ${
|
||||
statusColors[plant.status] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{plant.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600">Generation</p>
|
||||
<p className="text-2xl font-bold text-green-800">
|
||||
{plant.generation}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600">Descendants</p>
|
||||
<p className="text-2xl font-bold text-blue-800">
|
||||
{plant.childPlants.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600">Propagation Type</p>
|
||||
<p className="text-2xl font-bold text-purple-800 capitalize">
|
||||
{plant.propagationType || 'Original'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||||
activeTab === 'details'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
📋 Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('lineage')}
|
||||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||||
activeTab === 'lineage'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
🌳 Family Tree
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('environment')}
|
||||
className={`px-6 py-3 rounded-lg font-semibold transition ${
|
||||
activeTab === 'environment'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
🌍 Environment
|
||||
{!plant.environment && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-yellow-500 text-white text-xs rounded-full">
|
||||
Add
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Plant Information */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Plant Information
|
||||
</h2>
|
||||
<dl className="space-y-3">
|
||||
<InfoRow label="Common Name" value={plant.commonName} />
|
||||
{plant.scientificName && (
|
||||
<InfoRow label="Scientific Name" value={plant.scientificName} />
|
||||
)}
|
||||
{plant.genus && <InfoRow label="Genus" value={plant.genus} />}
|
||||
{plant.family && <InfoRow label="Family" value={plant.family} />}
|
||||
<InfoRow
|
||||
label="Planted Date"
|
||||
value={new Date(plant.plantedDate).toLocaleDateString()}
|
||||
/>
|
||||
<InfoRow label="Status" value={plant.status} />
|
||||
<InfoRow
|
||||
label="Registered"
|
||||
value={new Date(plant.registeredAt).toLocaleDateString()}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{plant.notes && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Notes</h3>
|
||||
<p className="text-gray-700 bg-gray-50 p-3 rounded-lg">
|
||||
{plant.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location & Owner */}
|
||||
<div className="space-y-6">
|
||||
{/* Owner */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Owner
|
||||
</h2>
|
||||
<dl className="space-y-3">
|
||||
<InfoRow label="Name" value={plant.owner.name} />
|
||||
<InfoRow label="Email" value={plant.owner.email} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Location
|
||||
</h2>
|
||||
<dl className="space-y-3">
|
||||
{plant.location.city && (
|
||||
<InfoRow label="City" value={plant.location.city} />
|
||||
)}
|
||||
{plant.location.country && (
|
||||
<InfoRow label="Country" value={plant.location.country} />
|
||||
)}
|
||||
<InfoRow
|
||||
label="Coordinates"
|
||||
value={`${plant.location.latitude.toFixed(4)}, ${plant.location.longitude.toFixed(4)}`}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lineage Tab */}
|
||||
{activeTab === 'lineage' && lineage && (
|
||||
<div className="space-y-6">
|
||||
{/* Ancestors */}
|
||||
{lineage.ancestors.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
🌲 Ancestors ({lineage.ancestors.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{lineage.ancestors.map((ancestor, idx) => (
|
||||
<PlantLineageCard
|
||||
key={ancestor.id}
|
||||
plant={ancestor}
|
||||
label={`Generation ${ancestor.generation}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Siblings */}
|
||||
{lineage.siblings.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
👥 Siblings ({lineage.siblings.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{lineage.siblings.map((sibling) => (
|
||||
<PlantLineageCard key={sibling.id} plant={sibling} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Descendants */}
|
||||
{lineage.descendants.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
🌱 Descendants ({lineage.descendants.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{lineage.descendants.map((descendant) => (
|
||||
<PlantLineageCard
|
||||
key={descendant.id}
|
||||
plant={descendant}
|
||||
label={`Gen ${descendant.generation} (${descendant.propagationType})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lineage.ancestors.length === 0 &&
|
||||
lineage.siblings.length === 0 &&
|
||||
lineage.descendants.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
|
||||
<p className="text-gray-600 text-lg">
|
||||
This plant has no recorded lineage yet.
|
||||
<br />
|
||||
<Link href={`/plants/clone?parentId=${plant.id}`}>
|
||||
<a className="text-green-600 hover:underline font-semibold">
|
||||
Create a clone to start building the family tree!
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Tab */}
|
||||
{activeTab === 'environment' && (
|
||||
<div>
|
||||
{plant.environment ? (
|
||||
<EnvironmentalDisplay
|
||||
environment={plant.environment}
|
||||
plantId={plant.id}
|
||||
showRecommendations={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
|
||||
<div className="text-6xl mb-4">🌍</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
No Environmental Data Yet
|
||||
</h3>
|
||||
<p className="text-gray-600 text-lg mb-6">
|
||||
Track soil, climate, nutrients, and growing conditions to:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto mb-8">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="text-3xl mb-2">💡</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Get Recommendations</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receive personalized advice to optimize conditions
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="text-3xl mb-2">🔍</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Compare & Learn</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Find what works for similar plants
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="text-3xl mb-2">📈</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Track Success</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Monitor growth and health over time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
📘 Learn more in the{' '}
|
||||
<a
|
||||
href="https://github.com/yourusername/localgreenchain/blob/main/ENVIRONMENTAL_TRACKING.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:underline"
|
||||
>
|
||||
Environmental Tracking Guide
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => alert('Environmental data editing coming soon! Use API for now.')}
|
||||
className="px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
Add Environmental Data
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-600">{label}</dt>
|
||||
<dd className="text-base text-gray-900 capitalize">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlantLineageCard({
|
||||
plant,
|
||||
label,
|
||||
}: {
|
||||
plant: Plant;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={`/plants/${plant.id}`}>
|
||||
<a className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition border border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{plant.commonName}</h4>
|
||||
{plant.scientificName && (
|
||||
<p className="text-sm italic text-gray-600">
|
||||
{plant.scientificName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
👤 {plant.owner.name}
|
||||
</p>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
430
pages/plants/clone.tsx
Normal file
430
pages/plants/clone.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function ClonePlant() {
|
||||
const router = useRouter();
|
||||
const { parentId } = router.query;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [parentPlant, setParentPlant] = useState<any>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
propagationType: 'clone' as 'seed' | 'clone' | 'cutting' | 'division' | 'grafting',
|
||||
plantedDate: new Date().toISOString().split('T')[0],
|
||||
status: 'sprouted' as const,
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
city: '',
|
||||
country: '',
|
||||
ownerName: '',
|
||||
ownerEmail: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
fetchParentPlant();
|
||||
}
|
||||
}, [parentId]);
|
||||
|
||||
const fetchParentPlant = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/plants/${parentId}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setParentPlant(data.plant);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching parent plant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const newPlant = {
|
||||
plantedDate: formData.plantedDate,
|
||||
status: formData.status,
|
||||
location: {
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
city: formData.city || undefined,
|
||||
country: formData.country || undefined,
|
||||
},
|
||||
owner: {
|
||||
id: `user-${Date.now()}`,
|
||||
name: formData.ownerName,
|
||||
email: formData.ownerEmail,
|
||||
},
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/plants/clone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
parentPlantId: parentId,
|
||||
propagationType: formData.propagationType,
|
||||
newPlant,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to clone plant');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push(`/plants/${data.plant.id}`);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
latitude: position.coords.latitude.toString(),
|
||||
longitude: position.coords.longitude.toString(),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
setError('Unable to get your location: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setError('Geolocation is not supported by your browser');
|
||||
}
|
||||
};
|
||||
|
||||
if (!parentId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Error</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
No parent plant specified. Please select a plant to clone.
|
||||
</p>
|
||||
<Link href="/plants/explore">
|
||||
<a className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||||
Browse Plants
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
<Head>
|
||||
<title>Clone Plant - 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/explore">
|
||||
<a className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Explore Network
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Clone Plant
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Register a new offspring from an existing plant.
|
||||
</p>
|
||||
|
||||
{/* Parent Plant Info */}
|
||||
{parentPlant && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-8">
|
||||
<h2 className="text-lg font-semibold text-green-900 mb-2">
|
||||
Parent Plant
|
||||
</h2>
|
||||
<p className="text-green-800">
|
||||
<strong>{parentPlant.commonName}</strong>
|
||||
{parentPlant.scientificName && (
|
||||
<span className="italic"> ({parentPlant.scientificName})</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Generation {parentPlant.generation} • Owned by{' '}
|
||||
{parentPlant.owner.name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
||||
Plant cloned successfully! Redirecting to plant page...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Propagation Type */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Propagation Method
|
||||
</h2>
|
||||
<select
|
||||
name="propagationType"
|
||||
required
|
||||
value={formData.propagationType}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
<option value="clone">Clone (exact genetic copy)</option>
|
||||
<option value="seed">Seed</option>
|
||||
<option value="cutting">Cutting</option>
|
||||
<option value="division">Division</option>
|
||||
<option value="grafting">Grafting</option>
|
||||
</select>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{formData.propagationType === 'clone' &&
|
||||
'An exact genetic copy of the parent plant'}
|
||||
{formData.propagationType === 'seed' &&
|
||||
'Grown from seed (may have genetic variation)'}
|
||||
{formData.propagationType === 'cutting' &&
|
||||
'Propagated from a stem, leaf, or root cutting'}
|
||||
{formData.propagationType === 'division' &&
|
||||
'Separated from the parent plant'}
|
||||
{formData.propagationType === 'grafting' &&
|
||||
'Grafted onto another rootstock'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plant Status */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Current Status
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Planted Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="plantedDate"
|
||||
required
|
||||
value={formData.plantedDate}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Status *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
<option value="sprouted">Sprouted</option>
|
||||
<option value="growing">Growing</option>
|
||||
<option value="mature">Mature</option>
|
||||
<option value="flowering">Flowering</option>
|
||||
<option value="fruiting">Fruiting</option>
|
||||
<option value="dormant">Dormant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Location
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Latitude *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="latitude"
|
||||
required
|
||||
value={formData.latitude}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
name="longitude"
|
||||
required
|
||||
value={formData.longitude}
|
||||
onChange={handleChange}
|
||||
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 className="md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={getCurrentLocation}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
📍 Use My Current Location
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
{/* Owner Information */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Your Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ownerName"
|
||||
required
|
||||
value={formData.ownerName}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Your Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="ownerEmail"
|
||||
required
|
||||
value={formData.ownerEmail}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="Add any notes about this plant or how you obtained it..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Cloning...' : 'Register Clone'}
|
||||
</button>
|
||||
<Link href={`/plants/${parentId}`}>
|
||||
<a className="px-6 py-3 bg-gray-200 text-gray-700 font-semibold rounded-lg hover:bg-gray-300 transition">
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
pages/plants/explore.tsx
Normal file
333
pages/plants/explore.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
360
pages/plants/register-anonymous.tsx
Normal file
360
pages/plants/register-anonymous.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import PrivacySettings from '../../components/PrivacySettings';
|
||||
import { PrivacySettings as IPrivacySettings } from '../../lib/privacy/anonymity';
|
||||
|
||||
export default function RegisterAnonymousPlant() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [walletAddress, setWalletAddress] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
commonName: '',
|
||||
scientificName: '',
|
||||
species: '',
|
||||
genus: '',
|
||||
family: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
pseudonym: '',
|
||||
encryptionKey: '',
|
||||
});
|
||||
|
||||
const [privacySettings, setPrivacySettings] = useState<IPrivacySettings>({
|
||||
anonymousMode: true,
|
||||
locationPrivacy: 'fuzzy',
|
||||
identityPrivacy: 'anonymous',
|
||||
sharePlantDetails: true,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/plants/register-anonymous', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
commonName: formData.commonName,
|
||||
scientificName: formData.scientificName || undefined,
|
||||
species: formData.species || undefined,
|
||||
genus: formData.genus || undefined,
|
||||
family: formData.family || undefined,
|
||||
location: {
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
},
|
||||
privacySettings,
|
||||
pseudonym: formData.pseudonym || undefined,
|
||||
encryptionKey: formData.encryptionKey || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to register plant');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setWalletAddress(data.privacy.walletAddress);
|
||||
|
||||
setTimeout(() => {
|
||||
router.push(`/plants/${data.plant.id}`);
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
latitude: position.coords.latitude.toString(),
|
||||
longitude: position.coords.longitude.toString(),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
setError('Unable to get your location: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setError('Geolocation is not supported by your browser');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-100">
|
||||
<Head>
|
||||
<title>Anonymous Plant Registration - LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b-2 border-purple-200">
|
||||
<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-purple-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">
|
||||
Standard Registration
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/plants/explore">
|
||||
<a className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Explore Network
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-6xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
🔒 Anonymous Plant Registration
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Register your plant with maximum privacy protection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-6 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
||||
<h3 className="font-bold text-lg mb-2">
|
||||
✓ Plant registered anonymously!
|
||||
</h3>
|
||||
<p className="mb-2">Your anonymous wallet address:</p>
|
||||
<p className="font-mono bg-white p-2 rounded text-sm break-all">
|
||||
{walletAddress}
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Save this address to manage your plant. Redirecting...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Privacy Settings */}
|
||||
<div className="lg:col-span-1">
|
||||
<PrivacySettings
|
||||
value={privacySettings}
|
||||
onChange={setPrivacySettings}
|
||||
showTorStatus={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Plant Information */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Plant Information
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Plant Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Common Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="commonName"
|
||||
required
|
||||
value={formData.commonName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="e.g., Tomato, Basil"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{privacySettings.sharePlantDetails && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Scientific Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="scientificName"
|
||||
value={formData.scientificName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="e.g., Solanum lycopersicum"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Genus
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="genus"
|
||||
value={formData.genus}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Family
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="family"
|
||||
value={formData.family}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Location (will be obfuscated based on privacy settings)
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Latitude *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="latitude"
|
||||
required
|
||||
value={formData.latitude}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-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"
|
||||
name="longitude"
|
||||
required
|
||||
value={formData.longitude}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={getCurrentLocation}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
📍 Use My Current Location
|
||||
</button>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Your exact location will be obfuscated based on your
|
||||
privacy settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Pseudonym */}
|
||||
{privacySettings.identityPrivacy === 'pseudonym' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Pseudonym (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="pseudonym"
|
||||
value={formData.pseudonym}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Your chosen display name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Encryption Key */}
|
||||
{privacySettings.anonymousMode && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Encryption Key (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="encryptionKey"
|
||||
value={formData.encryptionKey}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Optional password for extra security"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Used to generate your anonymous contact address
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-purple-600 text-white font-semibold rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Registering...' : '🔒 Register Anonymously'}
|
||||
</button>
|
||||
<Link href="/">
|
||||
<a className="px-6 py-3 bg-gray-200 text-gray-700 font-semibold rounded-lg hover:bg-gray-300 transition">
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
pages/plants/register.tsx
Normal file
412
pages/plants/register.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function RegisterPlant() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
id: `plant-${Date.now()}`,
|
||||
commonName: '',
|
||||
scientificName: '',
|
||||
species: '',
|
||||
genus: '',
|
||||
family: '',
|
||||
propagationType: 'original' as const,
|
||||
plantedDate: new Date().toISOString().split('T')[0],
|
||||
status: 'sprouted' as const,
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
address: '',
|
||||
city: '',
|
||||
country: '',
|
||||
ownerName: '',
|
||||
ownerEmail: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Prepare plant data
|
||||
const plantData = {
|
||||
id: formData.id,
|
||||
commonName: formData.commonName,
|
||||
scientificName: formData.scientificName || undefined,
|
||||
species: formData.species || undefined,
|
||||
genus: formData.genus || undefined,
|
||||
family: formData.family || undefined,
|
||||
propagationType: formData.propagationType,
|
||||
generation: 0,
|
||||
plantedDate: formData.plantedDate,
|
||||
status: formData.status,
|
||||
location: {
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
address: formData.address || undefined,
|
||||
city: formData.city || undefined,
|
||||
country: formData.country || undefined,
|
||||
},
|
||||
owner: {
|
||||
id: `user-${Date.now()}`,
|
||||
name: formData.ownerName,
|
||||
email: formData.ownerEmail,
|
||||
},
|
||||
childPlants: [],
|
||||
notes: formData.notes || undefined,
|
||||
registeredAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch('/api/plants/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(plantData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to register plant');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push(`/plants/${data.plant.id}`);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
latitude: position.coords.latitude.toString(),
|
||||
longitude: position.coords.longitude.toString(),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
setError('Unable to get your location: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setError('Geolocation is not supported by your browser');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
<Head>
|
||||
<title>Register Plant - 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/explore">
|
||||
<a className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Explore Network
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Register a New Plant
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Add your plant to the blockchain and start tracking its lineage.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
||||
Plant registered successfully! Redirecting to plant page...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Plant Information */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Plant Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Common Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="commonName"
|
||||
required
|
||||
value={formData.commonName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="e.g., Tomato, Basil, Oak Tree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Scientific Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="scientificName"
|
||||
value={formData.scientificName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="e.g., Solanum lycopersicum"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Genus
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="genus"
|
||||
value={formData.genus}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Family
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="family"
|
||||
value={formData.family}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Planted Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="plantedDate"
|
||||
required
|
||||
value={formData.plantedDate}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Status *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
<option value="sprouted">Sprouted</option>
|
||||
<option value="growing">Growing</option>
|
||||
<option value="mature">Mature</option>
|
||||
<option value="flowering">Flowering</option>
|
||||
<option value="fruiting">Fruiting</option>
|
||||
<option value="dormant">Dormant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Location
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Latitude *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="latitude"
|
||||
required
|
||||
value={formData.latitude}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
name="longitude"
|
||||
required
|
||||
value={formData.longitude}
|
||||
onChange={handleChange}
|
||||
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 className="md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={getCurrentLocation}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
📍 Use My Current Location
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
{/* Owner Information */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Your Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ownerName"
|
||||
required
|
||||
value={formData.ownerName}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Your Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="ownerEmail"
|
||||
required
|
||||
value={formData.ownerEmail}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="Add any additional information about your plant..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Registering...' : 'Register Plant'}
|
||||
</button>
|
||||
<Link href="/">
|
||||
<a className="px-6 py-3 bg-gray-200 text-gray-700 font-semibold rounded-lg hover:bg-gray-300 transition">
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
tor/torrc.example
Normal file
41
tor/torrc.example
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Tor Configuration for LocalGreenChain Hidden Service
|
||||
# Copy this file to /etc/tor/torrc or ~/.tor/torrc and edit as needed
|
||||
|
||||
# SOCKS proxy port (for outgoing Tor connections)
|
||||
SocksPort 9050
|
||||
|
||||
# Control port for managing Tor
|
||||
ControlPort 9051
|
||||
|
||||
# Hidden Service Configuration
|
||||
# This allows your LocalGreenChain instance to be accessible via .onion address
|
||||
HiddenServiceDir /var/lib/tor/localgreenchain/
|
||||
HiddenServicePort 80 127.0.0.1:3001
|
||||
|
||||
# Optional: Multiple hidden service ports
|
||||
# HiddenServicePort 443 127.0.0.1:3001
|
||||
|
||||
# Logging (for debugging)
|
||||
Log notice file /var/log/tor/notices.log
|
||||
|
||||
# Privacy and Security Settings
|
||||
# Reject non-anonymous single hop circuits
|
||||
RejectAllOutboundTraffic 0
|
||||
|
||||
# Circuit isolation for better privacy
|
||||
IsolateDestAddr 1
|
||||
IsolateDestPort 1
|
||||
|
||||
# Bridge configuration (if needed to bypass censorship)
|
||||
# UseBridges 1
|
||||
# Bridge obfs4 [bridge address]
|
||||
|
||||
# Bandwidth settings (optional)
|
||||
# RelayBandwidthRate 100 KB
|
||||
# RelayBandwidthBurst 200 KB
|
||||
|
||||
# Directory for storing Tor data
|
||||
DataDirectory /var/lib/tor
|
||||
|
||||
# Run as daemon
|
||||
RunAsDaemon 1
|
||||
Loading…
Reference in a new issue